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 chatIconDark from "~/assets/chat-dark.json";
|
||||||
import chatIconLight from "~/assets/chat-light.json";
|
import chatIconLight from "~/assets/chat-light.json";
|
||||||
|
import Button from "~/components/Button";
|
||||||
|
import Input from "~/components/Input";
|
||||||
import LottieIcon from "~/components/LottieIcon";
|
import LottieIcon from "~/components/LottieIcon";
|
||||||
|
import { usePopup } from "~/providers/popup";
|
||||||
import { useTheme } from "~/providers/theme";
|
import { useTheme } from "~/providers/theme";
|
||||||
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
|
interface NewFeedbackFormInput {
|
||||||
|
feedback: string;
|
||||||
|
}
|
||||||
|
|
||||||
const FeedbackButton: React.FC = () => {
|
const FeedbackButton: React.FC = () => {
|
||||||
|
const outsideRef = useRef<HTMLDivElement>(null);
|
||||||
const { activeTheme } = useTheme();
|
const { activeTheme } = useTheme();
|
||||||
|
const { showPopup } = usePopup();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [index, setIndex] = useState(0);
|
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 = () => {
|
const handleMouseEnter = () => {
|
||||||
setIsHovered(true);
|
setIsHovered(true);
|
||||||
setIndex((index) => index + 1);
|
setIndex((index) => index + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSubmit = (values: NewFeedbackFormInput) => {
|
||||||
|
createFeedback.mutate({
|
||||||
|
feedback: values.feedback,
|
||||||
|
url: window.location.href,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<div ref={outsideRef} />
|
||||||
onMouseEnter={handleMouseEnter}
|
<Menu as="div" className="relative inline-block text-left">
|
||||||
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"
|
<div>
|
||||||
>
|
<Menu.Button
|
||||||
<LottieIcon
|
onMouseEnter={handleMouseEnter}
|
||||||
index={index}
|
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"
|
||||||
json={activeTheme === "dark" ? chatIconDark : chatIconLight}
|
>
|
||||||
isPlaying={isHovered}
|
<LottieIcon
|
||||||
/>
|
index={index}
|
||||||
<span className="ml-1">Feedback</span>
|
json={activeTheme === "dark" ? chatIconDark : chatIconLight}
|
||||||
</button>
|
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;
|
contentEditable?: boolean;
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
iconRight?: React.ReactNode;
|
iconRight?: React.ReactNode;
|
||||||
|
minHeight?: number;
|
||||||
value?: string;
|
value?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
onChange?: (
|
onChange?: (
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
) => void;
|
) => void;
|
||||||
|
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
@@ -22,6 +24,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
prefix,
|
prefix,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
onKeyDown,
|
||||||
iconRight,
|
iconRight,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
@@ -34,7 +37,11 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
placeholder={props.placeholder}
|
placeholder={props.placeholder}
|
||||||
html={value ?? ""}
|
html={value ?? ""}
|
||||||
onChange={onChange}
|
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 { authRouter } from "./routers/auth";
|
||||||
import { boardRouter } from "./routers/board";
|
import { boardRouter } from "./routers/board";
|
||||||
import { cardRouter } from "./routers/card";
|
import { cardRouter } from "./routers/card";
|
||||||
|
import { feedbackRouter } from "./routers/feedback";
|
||||||
import { importRouter } from "./routers/import";
|
import { importRouter } from "./routers/import";
|
||||||
import { labelRouter } from "./routers/label";
|
import { labelRouter } from "./routers/label";
|
||||||
import { listRouter } from "./routers/list";
|
import { listRouter } from "./routers/list";
|
||||||
@@ -13,6 +14,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
auth: authRouter,
|
auth: authRouter,
|
||||||
board: boardRouter,
|
board: boardRouter,
|
||||||
card: cardRouter,
|
card: cardRouter,
|
||||||
|
feedback: feedbackRouter,
|
||||||
label: labelRouter,
|
label: labelRouter,
|
||||||
list: listRouter,
|
list: listRouter,
|
||||||
member: memberRouter,
|
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,
|
"when": 1737130330536,
|
||||||
"tag": "0005_modern_gideon",
|
"tag": "0005_modern_gideon",
|
||||||
"breakpoints": true
|
"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 {
|
import {
|
||||||
bigint,
|
bigint,
|
||||||
bigserial,
|
bigserial,
|
||||||
|
boolean,
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
pgEnum,
|
pgEnum,
|
||||||
@@ -465,3 +466,22 @@ export const slugs = pgTable("workspace_slugs", {
|
|||||||
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
||||||
type: slugTypeEnum("type").notNull(),
|
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: {
|
import: {
|
||||||
Row: {
|
Row: {
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
|||||||
Reference in New Issue
Block a user