feat: feedback form
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
41
packages/api/src/routers/feedback.ts
Normal file
41
packages/api/src/routers/feedback.ts
Normal 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 };
|
||||
}),
|
||||
});
|
||||
15
packages/db/migrations/0006_spicy_mach_iv.sql
Normal file
15
packages/db/migrations/0006_spicy_mach_iv.sql
Normal 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 $$;
|
||||
1650
packages/db/migrations/meta/0006_snapshot.json
Normal file
1650
packages/db/migrations/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
25
packages/db/src/repository/feedback.repo.ts
Normal file
25
packages/db/src/repository/feedback.repo.ts
Normal 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;
|
||||
};
|
||||
@@ -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],
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user