feat: feedback form

This commit is contained in:
Henry
2025-01-29 12:09:40 +00:00
parent 0521ad5e87
commit 8addf952a9
10 changed files with 1929 additions and 14 deletions

View File

@@ -1,33 +1,143 @@
import { useState } from "react";
import Link from "next/link";
import { Menu, Transition } from "@headlessui/react";
import { Fragment, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import chatIconDark from "~/assets/chat-dark.json";
import chatIconLight from "~/assets/chat-light.json";
import Button from "~/components/Button";
import Input from "~/components/Input";
import LottieIcon from "~/components/LottieIcon";
import { usePopup } from "~/providers/popup";
import { useTheme } from "~/providers/theme";
import { api } from "~/utils/api";
interface NewFeedbackFormInput {
feedback: string;
}
const FeedbackButton: React.FC = () => {
const outsideRef = useRef<HTMLDivElement>(null);
const { activeTheme } = useTheme();
const { showPopup } = usePopup();
const [isHovered, setIsHovered] = useState(false);
const [index, setIndex] = useState(0);
const { handleSubmit, setValue, watch, reset } =
useForm<NewFeedbackFormInput>({
defaultValues: {
feedback: "",
},
});
const createFeedback = api.feedback.create.useMutation({
onSuccess: async () => {
reset();
showPopup({
header: "Feedback sent",
message: "Thank you for your feedback!",
icon: "success",
});
},
onError: async () => {
showPopup({
header: "Unable to send feedback",
message: "Please try again later, or contact customer support.",
icon: "error",
});
},
});
const handleMouseEnter = () => {
setIsHovered(true);
setIndex((index) => index + 1);
};
const onSubmit = (values: NewFeedbackFormInput) => {
createFeedback.mutate({
feedback: values.feedback,
url: window.location.href,
});
};
return (
<button
type="button"
onMouseEnter={handleMouseEnter}
className="flex items-center rounded-md border-[1px] border-light-600 bg-light-50 px-2.5 py-1.5 text-sm font-normal text-neutral-900 shadow-sm dark:border-dark-400 dark:bg-dark-50 dark:text-dark-1000"
>
<LottieIcon
index={index}
json={activeTheme === "dark" ? chatIconDark : chatIconLight}
isPlaying={isHovered}
/>
<span className="ml-1">Feedback</span>
</button>
<>
<div ref={outsideRef} />
<Menu as="div" className="relative inline-block text-left">
<div>
<Menu.Button
onMouseEnter={handleMouseEnter}
className="flex items-center rounded-md border-[1px] border-light-600 bg-light-50 px-2.5 py-1.5 text-sm font-normal text-neutral-900 shadow-sm dark:border-dark-400 dark:bg-dark-50 dark:text-dark-1000"
>
<LottieIcon
index={index}
json={activeTheme === "dark" ? chatIconDark : chatIconLight}
isPlaying={isHovered}
/>
<span className="ml-1">Feedback</span>
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
as="div"
static
className="absolute right-0 z-30 mt-2 w-[350px] origin-top-right rounded-md border border-light-200 bg-light-50 p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-300"
>
<form onSubmit={handleSubmit(onSubmit)} className="p-1">
<Input
placeholder="Ideas to improve this page..."
onChange={(e) => {
setValue("feedback", e.target.value);
}}
onKeyDown={(e) => {
e.stopPropagation();
}}
value={watch("feedback")}
contentEditable
className="max-h-[300px] min-h-[100px]"
/>
<div className="flex flex-row items-center justify-between pt-2">
<div>
<p className="ml-2 text-xs text-neutral-900 dark:text-dark-1000">
Need help?{" "}
<Link
href="mailto:support@kanbn.com"
className="text-blue-600 underline"
>
Contact us
</Link>
, or see our{" "}
<Link
href="https://docs.kanbn.com"
target="_blank"
rel="noreferrer"
className="text-blue-600 underline"
>
docs
</Link>
.
</p>
</div>
<div className="justify-end">
<Button size="sm" isLoading={createFeedback.isPending}>
Send
</Button>
</div>
</div>
</form>
</Menu.Items>
</Transition>
</Menu>
</>
);
};

View File

@@ -6,12 +6,14 @@ interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
contentEditable?: boolean;
prefix?: string;
iconRight?: React.ReactNode;
minHeight?: number;
value?: string;
errorMessage?: string;
className?: string;
onChange?: (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => void;
onKeyDown?: (e: React.KeyboardEvent) => void;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
@@ -22,6 +24,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
prefix,
value,
onChange,
onKeyDown,
iconRight,
className,
...props
@@ -34,7 +37,11 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
placeholder={props.placeholder}
html={value ?? ""}
onChange={onChange}
className="block min-h-[70px] w-full cursor-text rounded-md border-0 bg-dark-300 bg-white/5 px-3 py-1.5 text-light-900 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-600 focus-visible:outline-none dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6"
onKeyDown={onKeyDown}
className={twMerge(
"block min-h-[70px] w-full cursor-text overflow-y-auto rounded-md border-0 bg-dark-300 bg-white/5 px-3 py-1.5 text-light-900 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-600 focus-visible:outline-none dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6",
className && className,
)}
/>
);
}

View File

@@ -1,6 +1,7 @@
import { authRouter } from "./routers/auth";
import { boardRouter } from "./routers/board";
import { cardRouter } from "./routers/card";
import { feedbackRouter } from "./routers/feedback";
import { importRouter } from "./routers/import";
import { labelRouter } from "./routers/label";
import { listRouter } from "./routers/list";
@@ -13,6 +14,7 @@ export const appRouter = createTRPCRouter({
auth: authRouter,
board: boardRouter,
card: cardRouter,
feedback: feedbackRouter,
label: labelRouter,
list: listRouter,
member: memberRouter,

View File

@@ -0,0 +1,41 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import * as feedbackRepo from "@kan/db/repository/feedback.repo";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const feedbackRouter = createTRPCRouter({
create: protectedProcedure
.meta({ enabled: false })
.input(
z.object({
feedback: z.string().min(1),
url: z.string().min(1),
}),
)
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const result = await feedbackRepo.create(ctx.adminDb, {
feedback: input.feedback,
createdBy: userId,
url: input.url,
});
if (!result?.id)
throw new TRPCError({
message: `Unable to create workspace`,
code: "INTERNAL_SERVER_ERROR",
});
return { success: true };
}),
});

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS "feedback" (
"id" bigserial PRIMARY KEY NOT NULL,
"feedback" text NOT NULL,
"createdBy" uuid NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp,
"url" text NOT NULL,
"reviewed" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "feedback" ADD CONSTRAINT "feedback_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,13 @@
"when": 1737130330536,
"tag": "0005_modern_gideon",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1738149668712,
"tag": "0006_spicy_mach_iv",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,25 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@kan/db/types/database.types";
export const create = async (
db: SupabaseClient<Database>,
feedbackInput: {
feedback: string;
createdBy: string;
url: string;
},
) => {
const { data } = await db
.from("feedback")
.insert({
feedback: feedbackInput.feedback,
createdBy: feedbackInput.createdBy,
url: feedbackInput.url,
})
.select(`id`)
.limit(1)
.single();
return data;
};

View File

@@ -2,6 +2,7 @@ import { relations } from "drizzle-orm";
import {
bigint,
bigserial,
boolean,
index,
integer,
pgEnum,
@@ -465,3 +466,22 @@ export const slugs = pgTable("workspace_slugs", {
slug: varchar("slug", { length: 255 }).notNull().unique(),
type: slugTypeEnum("type").notNull(),
});
export const feedback = pgTable("feedback", {
id: bigserial("id", { mode: "number" }).primaryKey(),
feedback: text("feedback").notNull(),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
url: text("url").notNull(),
reviewed: boolean("reviewed").default(false).notNull(),
});
export const feedbackRelations = relations(feedback, ({ one }) => ({
createdBy: one(users, {
fields: [feedback.createdBy],
references: [users.id],
}),
}));

View File

@@ -394,6 +394,44 @@ export type Database = {
},
]
}
feedback: {
Row: {
createdAt: string
createdBy: string
feedback: string
id: number
reviewed: boolean
updatedAt: string | null
url: string
}
Insert: {
createdAt?: string
createdBy: string
feedback: string
id?: number
reviewed?: boolean
updatedAt?: string | null
url: string
}
Update: {
createdAt?: string
createdBy?: string
feedback?: string
id?: number
reviewed?: boolean
updatedAt?: string | null
url?: string
}
Relationships: [
{
foreignKeyName: "feedback_createdBy_user_id_fk"
columns: ["createdBy"]
isOneToOne: false
referencedRelation: "user"
referencedColumns: ["id"]
},
]
}
import: {
Row: {
createdAt: string