feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
/* eslint-disable i18next/no-literal-string */
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@turbostarter/ui-web/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Input } from "@turbostarter/ui-web/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@turbostarter/ui-web/select";
|
||||
import { Textarea } from "@turbostarter/ui-web/textarea";
|
||||
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
const formSchema = z.object({
|
||||
action: z.enum(["set", "add", "deduct"]),
|
||||
amount: z.coerce.number().int().positive("Amount must be positive"),
|
||||
reason: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
interface CreditsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
customer: {
|
||||
id: string;
|
||||
credits: number;
|
||||
user?: { name: string | null } | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function CreditsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
customer,
|
||||
}: CreditsDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(formSchema),
|
||||
defaultValues: {
|
||||
action: "add" as const,
|
||||
amount: 100,
|
||||
reason: "",
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: z.output<typeof formSchema>) => {
|
||||
const res = await api.admin.customers[":id"].credits.$patch({
|
||||
param: { id: customer.id },
|
||||
json: data,
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to update credits");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
toast.success(
|
||||
`Credits updated: ${result.previousBalance} → ${result.newBalance}`,
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "customers"] });
|
||||
onOpenChange(false);
|
||||
form.reset();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to update credits");
|
||||
},
|
||||
});
|
||||
|
||||
const watchAction = form.watch("action");
|
||||
const watchAmount = form.watch("amount");
|
||||
|
||||
const previewBalance = () => {
|
||||
const amount = Number(watchAmount) || 0;
|
||||
switch (watchAction) {
|
||||
case "set":
|
||||
return amount;
|
||||
case "add":
|
||||
return customer.credits + amount;
|
||||
case "deduct":
|
||||
return Math.max(0, customer.credits - amount);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (values: z.output<typeof formSchema>) => {
|
||||
mutation.mutate(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Credits</DialogTitle>
|
||||
<DialogDescription>
|
||||
{customer.user?.name ?? "Customer"} - Current balance:{" "}
|
||||
<strong>{customer.credits}</strong>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void form.handleSubmit(handleSubmit)(e);
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4 py-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="action"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Action</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="add">Add credits</SelectItem>
|
||||
<SelectItem value="deduct">Deduct credits</SelectItem>
|
||||
<SelectItem value="set">Set to exact amount</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="amount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Amount</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="100"
|
||||
{...field}
|
||||
value={field.value as number}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="reason"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Reason (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Support credit, promo, etc."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="rounded-md bg-muted p-3 text-sm">
|
||||
<span className="text-muted-foreground">New balance: </span>
|
||||
<strong>{previewBalance()}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Updating..." : "Update Credits"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
290
apps/web/src/modules/admin/customers/data-table/columns.tsx
Normal file
290
apps/web/src/modules/admin/customers/data-table/columns.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { BillingStatus, config } from "@turbostarter/billing";
|
||||
import { isKey, useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@turbostarter/ui-web/dropdown-menu";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
import { CreditsDialog } from "../components/credits-dialog";
|
||||
import { invalidateCustomers } from "../server/invalidate";
|
||||
import { UpdateCustomerPlanModal } from "../update-customer-plan";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { User } from "@turbostarter/auth";
|
||||
import type { Customer } from "@turbostarter/billing";
|
||||
|
||||
export const CustomerActions = ({
|
||||
customer,
|
||||
}: {
|
||||
customer: Customer & { user: Pick<User, "name">; credits: number };
|
||||
}) => {
|
||||
const { t } = useTranslation(["common", "admin", "billing"]);
|
||||
const queryClient = useQueryClient();
|
||||
const [creditsDialogOpen, setCreditsDialogOpen] = useState(false);
|
||||
|
||||
const deleteCustomer = useMutation({
|
||||
...admin.mutations.customers.delete,
|
||||
onSuccess: async () => {
|
||||
await invalidateCustomers();
|
||||
await queryClient.invalidateQueries(
|
||||
admin.queries.users.getPlans({
|
||||
id: customer.userId,
|
||||
}),
|
||||
);
|
||||
toast.success(t("customers.customer.delete.success"));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<span className="sr-only">{t("actions")}</span>
|
||||
<Icons.Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<UpdateCustomerPlanModal
|
||||
customer={customer}
|
||||
key={`update-plan-${customer.id}`}
|
||||
>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
{t("updatePlan")}
|
||||
</DropdownMenuItem>
|
||||
</UpdateCustomerPlanModal>
|
||||
<DropdownMenuItem onSelect={() => setCreditsDialogOpen(true)}>
|
||||
<Icons.HandCoins className="mr-2 size-4" />
|
||||
{t("manageCredits", { defaultValue: "Manage Credits" })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{(() => {
|
||||
const isPending =
|
||||
deleteCustomer.isPending &&
|
||||
deleteCustomer.variables.id === customer.id;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
deleteCustomer.mutate({
|
||||
id: customer.id,
|
||||
})
|
||||
}
|
||||
disabled={isPending}
|
||||
key={`remove-${customer.id}`}
|
||||
>
|
||||
{t("delete")}
|
||||
{isPending && (
|
||||
<Icons.Loader2 className="ml-auto animate-spin text-current" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})()}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<CreditsDialog
|
||||
open={creditsDialogOpen}
|
||||
onOpenChange={setCreditsDialogOpen}
|
||||
customer={customer}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const useColumns = (): ColumnDef<
|
||||
Customer & { user: Pick<User, "name" | "image">; credits: number }
|
||||
>[] => {
|
||||
const { t, i18n } = useTranslation(["common", "billing"]);
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
return [
|
||||
{
|
||||
id: "q",
|
||||
accessorKey: "q",
|
||||
meta: {
|
||||
placeholder: `${t("searchPlaceholder")}`,
|
||||
variant: "text",
|
||||
},
|
||||
enableHiding: false,
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "user.name",
|
||||
accessorKey: "user.name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("name")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<TurboLink
|
||||
href={pathsConfig.admin.users.user(row.original.userId)}
|
||||
className="group flex items-center gap-3"
|
||||
>
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={row.original.user.image ?? undefined}
|
||||
alt={row.original.user.name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<Icons.UserRound className="w-5" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="group-hover:text-primary truncate font-medium underline underline-offset-4">
|
||||
{row.original.user.name}
|
||||
</span>
|
||||
{row.original.userId === session?.user.id && (
|
||||
<Badge variant="outline">{t("you")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TurboLink>
|
||||
);
|
||||
},
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "customerId",
|
||||
accessorKey: "customerId",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("id")} />
|
||||
),
|
||||
meta: {
|
||||
label: t("id"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
accessorKey: "plan",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("common:plan")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const plan = config.plans.find((plan) => plan.id === row.original.plan);
|
||||
|
||||
if (!plan) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="outline">
|
||||
{isKey(plan.name, i18n, "billing") ? t(plan.name) : plan.name}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("common:plan"),
|
||||
variant: "multiSelect",
|
||||
options: Object.values(config.plans).map((plan) => ({
|
||||
label: isKey(plan.name, i18n, "billing") ? t(plan.name) : plan.name,
|
||||
value: plan.id,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("common:status")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const statusKey = `status.${row.original.status?.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())}`;
|
||||
|
||||
if (!row.original.status) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
{isKey(statusKey, i18n, "billing") ? t(statusKey) : statusKey}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("common:status"),
|
||||
variant: "multiSelect",
|
||||
options: Object.values(BillingStatus).map((status) => {
|
||||
const statusKey = `status.${status.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())}`;
|
||||
return {
|
||||
label: isKey(statusKey, i18n, "billing") ? t(statusKey) : statusKey,
|
||||
value: status,
|
||||
};
|
||||
}),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "credits",
|
||||
accessorKey: "credits",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Credits" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icons.HandCoins className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium tabular-nums">
|
||||
{row.original.credits.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: "Credits",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<DataTableColumnHeader column={column} title={t("createdAt")} />
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="ml-auto text-right">
|
||||
{row.original.createdAt.toLocaleDateString(i18n.language)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("createdAt"),
|
||||
variant: "dateRange",
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<CustomerActions customer={row.original} />
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
|
||||
import { DataTable } from "@turbostarter/ui-web/data-table/data-table";
|
||||
import { DataTableToolbar } from "@turbostarter/ui-web/data-table/data-table-toolbar";
|
||||
|
||||
import { useDataTable } from "~/modules/common/hooks/use-data-table";
|
||||
|
||||
import { useColumns } from "./columns";
|
||||
|
||||
import type { GetCustomersResponse } from "@turbostarter/api/schema";
|
||||
|
||||
interface CustomersDataTableProps {
|
||||
readonly promise: Promise<Awaited<GetCustomersResponse>>;
|
||||
readonly perPage: number;
|
||||
}
|
||||
|
||||
export const CustomersDataTable = ({
|
||||
promise,
|
||||
perPage,
|
||||
}: CustomersDataTableProps) => {
|
||||
const columns = useColumns();
|
||||
const { data, total } = use(promise);
|
||||
|
||||
const { table } = useDataTable({
|
||||
persistance: "searchParams",
|
||||
data,
|
||||
columns,
|
||||
pageCount: Math.ceil(total / perPage),
|
||||
initialState: {
|
||||
sorting: [
|
||||
{
|
||||
id: "user.name",
|
||||
desc: false,
|
||||
},
|
||||
],
|
||||
columnVisibility: {
|
||||
q: false,
|
||||
},
|
||||
},
|
||||
shallow: false,
|
||||
clearOnDefault: true,
|
||||
enableRowSelection: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<DataTableToolbar table={table} />
|
||||
<DataTable table={table} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
export const invalidateCustomers = async () =>
|
||||
revalidatePath(pathsConfig.admin.customers.index);
|
||||
224
apps/web/src/modules/admin/customers/update-customer-plan.tsx
Normal file
224
apps/web/src/modules/admin/customers/update-customer-plan.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { updateCustomerInputSchema } from "@turbostarter/api/schema";
|
||||
import { BillingStatus, config } from "@turbostarter/billing";
|
||||
import { isKey, useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormDescription,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalDescription,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalTrigger,
|
||||
} from "@turbostarter/ui-web/modal";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@turbostarter/ui-web/select";
|
||||
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
|
||||
import { invalidateCustomers } from "./server/invalidate";
|
||||
|
||||
import type { UpdateCustomerInput } from "@turbostarter/api/schema";
|
||||
import type { User } from "@turbostarter/auth";
|
||||
import type { Customer } from "@turbostarter/billing";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
interface UpdateCustomerPlanModalProps {
|
||||
readonly customer: Customer & { user: Pick<User, "name"> };
|
||||
readonly children: React.ReactNode;
|
||||
}
|
||||
const UpdateCustomerPlanForm = ({
|
||||
form,
|
||||
onSubmit,
|
||||
}: {
|
||||
form: UseFormReturn<Pick<UpdateCustomerInput, "plan" | "status">>;
|
||||
onSubmit: (
|
||||
data: Pick<UpdateCustomerInput, "plan" | "status">,
|
||||
) => void | Promise<void>;
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation(["common", "admin", "billing"]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="plan"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common:plan")}</FormLabel>
|
||||
<FormControl>
|
||||
<div>
|
||||
<Select
|
||||
value={field.value ?? undefined}
|
||||
onValueChange={field.onChange}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t("common:plan")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.plans.map((plan) => (
|
||||
<SelectItem key={plan.id} value={plan.id}>
|
||||
{isKey(plan.name, i18n, "billing")
|
||||
? t(plan.name)
|
||||
: plan.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("customers.customer.updatePlan.plan.info")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common:status")}</FormLabel>
|
||||
<FormControl>
|
||||
<div>
|
||||
<Select
|
||||
value={field.value ?? undefined}
|
||||
onValueChange={field.onChange}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t("common:status")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(BillingStatus).map((status) => {
|
||||
const statusKey = `status.${status
|
||||
.toLowerCase()
|
||||
.replace(/_([a-z])/g, (_, letter: string) =>
|
||||
letter.toUpperCase(),
|
||||
)}`;
|
||||
return (
|
||||
<SelectItem key={status} value={status}>
|
||||
{isKey(statusKey, i18n, "billing")
|
||||
? t(statusKey)
|
||||
: statusKey}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("customers.customer.updatePlan.status.info")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ModalFooter>
|
||||
<ModalClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</ModalClose>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("update")
|
||||
)}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export const UpdateCustomerPlanModal = ({
|
||||
customer,
|
||||
children,
|
||||
}: UpdateCustomerPlanModalProps) => {
|
||||
const { t } = useTranslation(["common", "admin", "billing"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(
|
||||
updateCustomerInputSchema.pick({ plan: true, status: true }),
|
||||
),
|
||||
defaultValues: {
|
||||
plan: customer.plan ?? undefined,
|
||||
status: customer.status ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const updateCustomer = useMutation({
|
||||
...admin.mutations.customers.update,
|
||||
onSuccess: async () => {
|
||||
await invalidateCustomers();
|
||||
await queryClient.invalidateQueries(
|
||||
admin.queries.users.getPlans({
|
||||
id: customer.userId,
|
||||
}),
|
||||
);
|
||||
toast.success(t("customers.customer.updatePlan.success"));
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (
|
||||
data: Pick<UpdateCustomerInput, "plan" | "status">,
|
||||
) => {
|
||||
await updateCustomer.mutateAsync({ id: customer.id, ...data });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<ModalTrigger asChild>{children}</ModalTrigger>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>
|
||||
{t("customers.customer.updatePlan.title", {
|
||||
name: customer.user.name,
|
||||
})}
|
||||
</ModalTitle>
|
||||
<ModalDescription>
|
||||
{t("customers.customer.updatePlan.description")}
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
|
||||
<UpdateCustomerPlanForm form={form} onSubmit={onSubmit} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
29
apps/web/src/modules/admin/layout/details-list.tsx
Normal file
29
apps/web/src/modules/admin/layout/details-list.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
export const DetailsList = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) => {
|
||||
return (
|
||||
<ul
|
||||
className={cn(
|
||||
"xl:*:px- -m-px grid grid-cols-1 gap-y-8 @lg/details:grid-cols-2 @lg/details:gap-y-0 @3xl/details:grid-cols-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const DetailsListItem = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) => (
|
||||
<li
|
||||
className={cn(
|
||||
"px-1 @lg/details:border @lg/details:px-6 @lg/details:py-8 @3xl/details:px-8 @3xl/details:py-10 [&:not(:first-child)]:-ms-px [&:not(:first-child)]:-mt-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
90
apps/web/src/modules/admin/layout/sidebar.tsx
Normal file
90
apps/web/src/modules/admin/layout/sidebar.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { memo } from "react";
|
||||
|
||||
import { isKey } from "@turbostarter/i18n";
|
||||
import { getTranslation } from "@turbostarter/i18n/server";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
} from "@turbostarter/ui-web/sidebar";
|
||||
import { SidebarMenu } from "@turbostarter/ui-web/sidebar";
|
||||
import {
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
} from "@turbostarter/ui-web/sidebar";
|
||||
import { Sidebar } from "@turbostarter/ui-web/sidebar";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { SidebarLink } from "~/modules/common/layout/dashboard/sidebar-link";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
import { UserNavigation } from "~/modules/user/user-navigation";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
import type { Icon } from "@turbostarter/ui-web/icons";
|
||||
|
||||
interface AdminSidebarProps {
|
||||
readonly user: User;
|
||||
readonly menu: {
|
||||
label: string;
|
||||
items: {
|
||||
title: string;
|
||||
href: string;
|
||||
icon: Icon;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export const AdminSidebar = memo<AdminSidebarProps>(async ({ user, menu }) => {
|
||||
const { t, i18n } = await getTranslation({ ns: "common" });
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<TurboLink
|
||||
href={pathsConfig.index}
|
||||
className="flex items-center gap-3 p-2 transition-[padding] group-data-[collapsible=icon]:p-0.5"
|
||||
>
|
||||
<Icons.Logo className="text-primary h-8 transition-[width,height]" />
|
||||
<Icons.LogoText className="text-foreground h-4 group-data-[collapsible=icon]:hidden" />
|
||||
</TurboLink>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{menu.map((group) => (
|
||||
<SidebarGroup key={group.label}>
|
||||
<SidebarGroupLabel className="uppercase">
|
||||
{isKey(group.label, i18n, "common")
|
||||
? t(group.label)
|
||||
: group.label}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => {
|
||||
const title = isKey(item.title, i18n, "common")
|
||||
? t(item.title)
|
||||
: item.title;
|
||||
return (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarLink href={item.href} tooltip={title}>
|
||||
<item.icon />
|
||||
<span>{title}</span>
|
||||
</SidebarLink>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<UserNavigation user={user} />
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
});
|
||||
301
apps/web/src/modules/admin/lib/api.ts
Normal file
301
apps/web/src/modules/admin/lib/api.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import {
|
||||
getInvitationsResponseSchema,
|
||||
getMembersResponseSchema,
|
||||
getOrganizationResponseSchema,
|
||||
getUserAccountsResponseSchema,
|
||||
getUserInvitationsResponseSchema,
|
||||
getUserMembershipsResponseSchema,
|
||||
getUserPlansResponseSchema,
|
||||
} from "@turbostarter/api/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
import type { InferRequestType } from "hono/client";
|
||||
|
||||
const KEY = "admin";
|
||||
|
||||
const queries = {
|
||||
users: {
|
||||
get: ({ id }: { id: string }) => ({
|
||||
queryKey: [KEY, "users", id],
|
||||
queryFn: () =>
|
||||
authClient.admin.getUser({
|
||||
query: { id },
|
||||
fetchOptions: { throw: true },
|
||||
}) as Promise<User>,
|
||||
}),
|
||||
getAccounts: ({
|
||||
id,
|
||||
...query
|
||||
}: InferRequestType<
|
||||
(typeof api.admin.users)[":id"]["accounts"]["$get"]
|
||||
>["query"] & { id: string }) => ({
|
||||
queryKey: [KEY, "users", id, "accounts", query],
|
||||
queryFn: () =>
|
||||
handle(api.admin.users[":id"].accounts.$get, {
|
||||
schema: getUserAccountsResponseSchema,
|
||||
})({
|
||||
query,
|
||||
param: {
|
||||
id,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
getPlans: ({
|
||||
id,
|
||||
...query
|
||||
}: InferRequestType<
|
||||
(typeof api.admin.users)[":id"]["plans"]["$get"]
|
||||
>["query"] & { id: string }) => ({
|
||||
queryKey: [KEY, "users", id, "plans", query],
|
||||
queryFn: () =>
|
||||
handle(api.admin.users[":id"].plans.$get, {
|
||||
schema: getUserPlansResponseSchema,
|
||||
})({ query, param: { id } }),
|
||||
}),
|
||||
getMemberships: ({
|
||||
id,
|
||||
...query
|
||||
}: InferRequestType<
|
||||
(typeof api.admin.users)[":id"]["memberships"]["$get"]
|
||||
>["query"] & { id: string }) => ({
|
||||
queryKey: [KEY, "users", id, "memberships", query],
|
||||
queryFn: () =>
|
||||
handle(api.admin.users[":id"].memberships.$get, {
|
||||
schema: getUserMembershipsResponseSchema,
|
||||
})({ query, param: { id } }),
|
||||
}),
|
||||
getInvitations: ({
|
||||
id,
|
||||
...query
|
||||
}: InferRequestType<
|
||||
(typeof api.admin.users)[":id"]["invitations"]["$get"]
|
||||
>["query"] & { id: string }) => ({
|
||||
queryKey: [KEY, "users", id, "invitations", query],
|
||||
queryFn: () =>
|
||||
handle(api.admin.users[":id"].invitations.$get, {
|
||||
schema: getUserInvitationsResponseSchema,
|
||||
})({ query, param: { id } }),
|
||||
}),
|
||||
getSessions: ({ id }: { id: string }) => ({
|
||||
queryKey: [KEY, "users", id, "sessions"],
|
||||
queryFn: () =>
|
||||
authClient.admin.listUserSessions({
|
||||
userId: id,
|
||||
fetchOptions: { throw: true },
|
||||
}),
|
||||
}),
|
||||
},
|
||||
organizations: {
|
||||
get: ({ id }: { id: string }) => ({
|
||||
queryKey: [KEY, "organizations", id],
|
||||
queryFn: () =>
|
||||
handle(api.admin.organizations[":id"].$get, {
|
||||
schema: getOrganizationResponseSchema,
|
||||
})({
|
||||
param: { id },
|
||||
}),
|
||||
}),
|
||||
getMembers: ({
|
||||
id,
|
||||
...query
|
||||
}: InferRequestType<
|
||||
(typeof api.admin.organizations)[":id"]["members"]["$get"]
|
||||
>["query"] & { id: string }) => ({
|
||||
queryKey: [
|
||||
...queries.organizations.get({ id }).queryKey,
|
||||
"members",
|
||||
query,
|
||||
],
|
||||
queryFn: () =>
|
||||
handle(api.admin.organizations[":id"].members.$get, {
|
||||
schema: getMembersResponseSchema,
|
||||
})({
|
||||
query,
|
||||
param: {
|
||||
id,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
getInvitations: ({
|
||||
id,
|
||||
...query
|
||||
}: InferRequestType<
|
||||
(typeof api.admin.organizations)[":id"]["invitations"]["$get"]
|
||||
>["query"] & { id: string }) => ({
|
||||
queryKey: [
|
||||
...queries.organizations.get({ id }).queryKey,
|
||||
"invitations",
|
||||
query,
|
||||
],
|
||||
queryFn: () =>
|
||||
handle(api.admin.organizations[":id"].invitations.$get, {
|
||||
schema: getInvitationsResponseSchema,
|
||||
})({ query, param: { id } }),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
users: {
|
||||
ban: {
|
||||
mutationKey: [KEY, "users", "ban"],
|
||||
mutationFn: (params: Parameters<typeof authClient.admin.banUser>[0]) =>
|
||||
authClient.admin.banUser(params),
|
||||
},
|
||||
unban: {
|
||||
mutationKey: [KEY, "users", "unban"],
|
||||
mutationFn: (params: Parameters<typeof authClient.admin.unbanUser>[0]) =>
|
||||
authClient.admin.unbanUser(params),
|
||||
},
|
||||
delete: {
|
||||
mutationKey: [KEY, "users", "delete"],
|
||||
mutationFn: (params: Parameters<typeof authClient.admin.removeUser>[0]) =>
|
||||
authClient.admin.removeUser(params),
|
||||
},
|
||||
impersonate: {
|
||||
mutationKey: [KEY, "users", "impersonate"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.admin.impersonateUser>[0],
|
||||
) => authClient.admin.impersonateUser(params),
|
||||
},
|
||||
stopImpersonating: {
|
||||
mutationKey: [KEY, "users", "impersonate", "stop"],
|
||||
mutationFn: () => authClient.admin.stopImpersonating(),
|
||||
},
|
||||
update: {
|
||||
mutationKey: [KEY, "users", "update"],
|
||||
mutationFn: (params: Parameters<typeof authClient.admin.updateUser>[0]) =>
|
||||
authClient.admin.updateUser(params),
|
||||
},
|
||||
setPassword: {
|
||||
mutationKey: [KEY, "users", "setPassword"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.admin.setUserPassword>[0],
|
||||
) => authClient.admin.setUserPassword(params),
|
||||
},
|
||||
accounts: {
|
||||
delete: {
|
||||
mutationKey: [KEY, "users", "accounts", "delete"],
|
||||
mutationFn: (
|
||||
param: InferRequestType<
|
||||
(typeof api.admin.users)[":id"]["accounts"][":accountId"]["$delete"]
|
||||
>["param"],
|
||||
) =>
|
||||
handle(api.admin.users[":id"].accounts[":accountId"].$delete)({
|
||||
param,
|
||||
}),
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
revoke: {
|
||||
mutationKey: [KEY, "users", "sessions", "revoke"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.admin.revokeUserSession>[0],
|
||||
) => authClient.admin.revokeUserSession(params),
|
||||
},
|
||||
revokeAll: {
|
||||
mutationKey: [KEY, "users", "sessions", "revokeAll"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.admin.revokeUserSessions>[0],
|
||||
) => authClient.admin.revokeUserSessions(params),
|
||||
},
|
||||
},
|
||||
},
|
||||
organizations: {
|
||||
delete: {
|
||||
mutationKey: [KEY, "organizations", "delete"],
|
||||
mutationFn: (
|
||||
param: InferRequestType<
|
||||
(typeof api.admin.organizations)[":id"]["$delete"]
|
||||
>["param"],
|
||||
) =>
|
||||
handle(api.admin.organizations[":id"].$delete)({
|
||||
param,
|
||||
}),
|
||||
},
|
||||
update: {
|
||||
mutationKey: [KEY, "organizations", "update"],
|
||||
mutationFn: ({
|
||||
id,
|
||||
...json
|
||||
}: InferRequestType<
|
||||
(typeof api.admin.organizations)[":id"]["$patch"]
|
||||
>["json"] & { id: string }) =>
|
||||
handle(api.admin.organizations[":id"].$patch)({ param: { id }, json }),
|
||||
},
|
||||
members: {
|
||||
remove: {
|
||||
mutationKey: [KEY, "organizations", "members", "remove"],
|
||||
mutationFn: (
|
||||
param: InferRequestType<
|
||||
(typeof api.admin.organizations)[":id"]["members"][":memberId"]["$delete"]
|
||||
>["param"],
|
||||
) =>
|
||||
handle(api.admin.organizations[":id"].members[":memberId"].$delete)({
|
||||
param,
|
||||
}),
|
||||
},
|
||||
update: {
|
||||
mutationKey: [KEY, "organizations", "members", "update"],
|
||||
mutationFn: ({
|
||||
id,
|
||||
memberId,
|
||||
...json
|
||||
}: InferRequestType<
|
||||
(typeof api.admin.organizations)[":id"]["members"][":memberId"]["$patch"]
|
||||
>["json"] & { id: string; memberId: string }) =>
|
||||
handle(api.admin.organizations[":id"].members[":memberId"].$patch)({
|
||||
param: { id, memberId },
|
||||
json,
|
||||
}),
|
||||
},
|
||||
},
|
||||
invitations: {
|
||||
delete: {
|
||||
mutationKey: [KEY, "organizations", "invitations", "delete"],
|
||||
mutationFn: (
|
||||
param: InferRequestType<
|
||||
(typeof api.admin.organizations)[":id"]["invitations"][":invitationId"]["$delete"]
|
||||
>["param"],
|
||||
) =>
|
||||
handle(
|
||||
api.admin.organizations[":id"].invitations[":invitationId"].$delete,
|
||||
)({
|
||||
param,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
customers: {
|
||||
delete: {
|
||||
mutationKey: [KEY, "customers", "delete"],
|
||||
mutationFn: (
|
||||
param: InferRequestType<
|
||||
(typeof api.admin.customers)[":id"]["$delete"]
|
||||
>["param"],
|
||||
) => handle(api.admin.customers[":id"].$delete)({ param }),
|
||||
},
|
||||
update: {
|
||||
mutationKey: [KEY, "customers", "update"],
|
||||
mutationFn: ({
|
||||
id,
|
||||
...json
|
||||
}: InferRequestType<
|
||||
(typeof api.admin.customers)[":id"]["$patch"]
|
||||
>["json"] & { id: string }) =>
|
||||
handle(api.admin.customers[":id"].$patch)({
|
||||
param: { id },
|
||||
json,
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const admin = {
|
||||
queries,
|
||||
mutations,
|
||||
} as const;
|
||||
115
apps/web/src/modules/admin/organizations/data-table/columns.tsx
Normal file
115
apps/web/src/modules/admin/organizations/data-table/columns.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { GetOrganizationsResponse } from "@turbostarter/api/schema";
|
||||
|
||||
type Row = GetOrganizationsResponse["data"][number];
|
||||
|
||||
export const useColumns = (options?: {
|
||||
max?: { members: number };
|
||||
}): ColumnDef<Row>[] => {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
|
||||
return [
|
||||
{
|
||||
id: "q",
|
||||
accessorKey: "q",
|
||||
meta: {
|
||||
placeholder: `${t("searchPlaceholder")}`,
|
||||
variant: "text",
|
||||
},
|
||||
enableHiding: false,
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("name")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<TurboLink
|
||||
href={pathsConfig.admin.organizations.organization(row.original.id)}
|
||||
className="group flex items-center gap-3"
|
||||
>
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={row.original.logo ?? undefined}
|
||||
alt={row.original.name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<span className="text-muted-foreground text-sm uppercase">
|
||||
{row.original.name.charAt(0)}
|
||||
</span>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="group-hover:text-primary truncate font-medium underline underline-offset-4">
|
||||
{row.original.name}
|
||||
</span>
|
||||
</TurboLink>
|
||||
);
|
||||
},
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "slug",
|
||||
accessorKey: "slug",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("slug")} />
|
||||
),
|
||||
cell: ({ row }) => <span>/{row.original.slug}</span>,
|
||||
meta: {
|
||||
label: "Slug",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "members",
|
||||
accessorKey: "members",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("members")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <span>{row.original.members}</span>;
|
||||
},
|
||||
meta: {
|
||||
label: t("members"),
|
||||
variant: "range",
|
||||
range: [0, options?.max?.members ?? 100],
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<DataTableColumnHeader column={column} title={t("createdAt")} />
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="ml-auto text-right">
|
||||
{row.original.createdAt.toLocaleDateString(i18n.language)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("createdAt"),
|
||||
variant: "dateRange",
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
|
||||
import { DataTable } from "@turbostarter/ui-web/data-table/data-table";
|
||||
import { DataTableToolbar } from "@turbostarter/ui-web/data-table/data-table-toolbar";
|
||||
|
||||
import { useDataTable } from "~/modules/common/hooks/use-data-table";
|
||||
|
||||
import { useColumns } from "./columns";
|
||||
|
||||
import type { GetOrganizationsResponse } from "@turbostarter/api/schema";
|
||||
|
||||
interface OrganizationsDataTableProps {
|
||||
readonly promise: Promise<GetOrganizationsResponse>;
|
||||
readonly perPage: number;
|
||||
}
|
||||
|
||||
export const OrganizationsDataTable = ({
|
||||
promise,
|
||||
perPage,
|
||||
}: OrganizationsDataTableProps) => {
|
||||
const { data, total, max } = use<GetOrganizationsResponse>(promise);
|
||||
const columns = useColumns({ max });
|
||||
|
||||
const { table } = useDataTable({
|
||||
persistance: "searchParams",
|
||||
data,
|
||||
columns,
|
||||
pageCount: Math.ceil(total / perPage),
|
||||
initialState: {
|
||||
sorting: [
|
||||
{
|
||||
id: "name",
|
||||
desc: false,
|
||||
},
|
||||
],
|
||||
columnVisibility: {
|
||||
q: false,
|
||||
},
|
||||
},
|
||||
shallow: false,
|
||||
clearOnDefault: true,
|
||||
enableRowSelection: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<DataTableToolbar table={table} />
|
||||
<DataTable table={table} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useMutation, useMutationState, useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Modal,
|
||||
ModalTrigger,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalDescription,
|
||||
ModalFooter,
|
||||
ModalClose,
|
||||
} from "@turbostarter/ui-web/modal";
|
||||
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
|
||||
interface DeleteProps {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export const Delete = ({ id }: DeleteProps) => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
const [mutation] = useMutationState({
|
||||
filters: {
|
||||
mutationKey: admin.mutations.organizations.delete.mutationKey,
|
||||
},
|
||||
select: (mutation) => ({
|
||||
status: mutation.state.status,
|
||||
variables: mutation.state.variables as { id: string },
|
||||
}),
|
||||
});
|
||||
|
||||
const isPending =
|
||||
mutation?.status === "pending" && mutation.variables.id === id;
|
||||
|
||||
return (
|
||||
<ConfirmModal organizationId={id}>
|
||||
<Button variant="destructive" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<Icons.Trash />
|
||||
)}
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</ConfirmModal>
|
||||
);
|
||||
};
|
||||
|
||||
const ConfirmModal = ({
|
||||
children,
|
||||
organizationId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
organizationId: string;
|
||||
}) => {
|
||||
const { t } = useTranslation(["common", "admin"]);
|
||||
const router = useRouter();
|
||||
|
||||
const { data: organization } = useQuery(
|
||||
admin.queries.organizations.get({ id: organizationId }),
|
||||
);
|
||||
|
||||
const deleteOrganization = useMutation({
|
||||
...admin.mutations.organizations.delete,
|
||||
onSuccess: () => {
|
||||
toast.success(t("organizations.organization.delete.success"));
|
||||
router.back();
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteOrganization.mutate({ id: organizationId });
|
||||
};
|
||||
|
||||
const isPending =
|
||||
deleteOrganization.isPending &&
|
||||
deleteOrganization.variables.id === organizationId;
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<ModalTrigger asChild>{children}</ModalTrigger>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>
|
||||
{t("organizations.organization.delete.title", {
|
||||
name: organization?.name,
|
||||
})}
|
||||
</ModalTitle>
|
||||
<ModalDescription className="whitespace-pre-line">
|
||||
{t("organizations.organization.delete.disclaimer")}
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
<ModalFooter>
|
||||
<ModalClose asChild>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("delete")
|
||||
)}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { updateOrganizationSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { useDebounceCallback } from "@turbostarter/shared/hooks";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Input } from "@turbostarter/ui-web/input";
|
||||
|
||||
import {
|
||||
DetailsList,
|
||||
DetailsListItem,
|
||||
} from "~/modules/admin/layout/details-list";
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
|
||||
import type { UpdateOrganizationPayload } from "@turbostarter/auth";
|
||||
|
||||
const Name = ({ id }: { id: string }) => {
|
||||
const { t } = useTranslation(["common", "admin"]);
|
||||
const queryClient = useQueryClient();
|
||||
const { data: organization } = useQuery(
|
||||
admin.queries.organizations.get({ id }),
|
||||
);
|
||||
|
||||
const update = useMutation({
|
||||
...admin.mutations.organizations.update,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(
|
||||
admin.queries.organizations.get({ id }),
|
||||
);
|
||||
toast.success(
|
||||
t("organizations.organization.details.name.update.success"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(
|
||||
updateOrganizationSchema.pick({ name: true }),
|
||||
),
|
||||
defaultValues: {
|
||||
name: organization?.name,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: Pick<UpdateOrganizationPayload, "name">) => {
|
||||
await update.mutateAsync({ id, ...data });
|
||||
};
|
||||
|
||||
const debouncedOnSubmit = useDebounceCallback(
|
||||
form.handleSubmit(onSubmit),
|
||||
2000,
|
||||
{
|
||||
trailing: true,
|
||||
leading: false,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FormLabel>{t("name")}</FormLabel>
|
||||
{form.formState.isSubmitting && (
|
||||
<Icons.Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value);
|
||||
void debouncedOnSubmit();
|
||||
}}
|
||||
disabled={form.formState.isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
const Slug = ({ id }: { id: string }) => {
|
||||
const { t } = useTranslation(["common", "admin"]);
|
||||
const queryClient = useQueryClient();
|
||||
const { data: organization } = useQuery(
|
||||
admin.queries.organizations.get({ id }),
|
||||
);
|
||||
|
||||
const update = useMutation({
|
||||
...admin.mutations.organizations.update,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(
|
||||
admin.queries.organizations.get({ id }),
|
||||
);
|
||||
toast.success(
|
||||
t("organizations.organization.details.slug.update.success"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(
|
||||
updateOrganizationSchema.pick({ slug: true }),
|
||||
),
|
||||
defaultValues: {
|
||||
slug: organization?.slug ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: Pick<UpdateOrganizationPayload, "slug">) => {
|
||||
await update.mutateAsync({ id, ...data });
|
||||
};
|
||||
|
||||
const debouncedOnSubmit = useDebounceCallback(
|
||||
form.handleSubmit(onSubmit),
|
||||
2000,
|
||||
{
|
||||
trailing: true,
|
||||
leading: false,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FormLabel>{t("slug")}</FormLabel>
|
||||
{form.formState.isSubmitting && (
|
||||
<Icons.Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
className="peer ps-6"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value);
|
||||
void debouncedOnSubmit();
|
||||
}}
|
||||
disabled={form.formState.isSubmitting}
|
||||
/>
|
||||
<div className="text-muted-foreground/80 pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-sm peer-disabled:opacity-50">
|
||||
/
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
interface OrganizationDetailsProps {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export const OrganizationDetails = ({ id }: OrganizationDetailsProps) => {
|
||||
const { t, i18n } = useTranslation(["common", "admin"]);
|
||||
const { data: organization } = useQuery(
|
||||
admin.queries.organizations.get({ id }),
|
||||
);
|
||||
|
||||
const details = [
|
||||
{
|
||||
id: "name",
|
||||
component: <Name id={id} />,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: "slug",
|
||||
component: <Slug id={id} />,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
component: (
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<span className="text-sm font-medium">{t("createdAt")}</span>
|
||||
<span>{organization?.createdAt.toLocaleString(i18n.language)}</span>
|
||||
</div>
|
||||
),
|
||||
visible: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="@container/details w-full overflow-hidden">
|
||||
<DetailsList>
|
||||
{details
|
||||
.filter((detail) => detail.visible)
|
||||
.map((detail) => (
|
||||
<DetailsListItem key={detail.id}>
|
||||
{detail.component}
|
||||
</DetailsListItem>
|
||||
))}
|
||||
</DetailsList>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
import {
|
||||
DashboardHeader,
|
||||
DashboardHeaderTitle,
|
||||
DashboardHeaderDescription,
|
||||
} from "~/modules/common/layout/dashboard/header";
|
||||
|
||||
import { Delete } from "./actions/delete";
|
||||
|
||||
interface OrganizationHeaderProps {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export const OrganizationHeader = ({ id }: OrganizationHeaderProps) => {
|
||||
const { data: organization } = useQuery(
|
||||
admin.queries.organizations.get({ id }),
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardHeader>
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<Avatar className="size-12">
|
||||
<AvatarImage
|
||||
src={organization?.logo ?? undefined}
|
||||
alt={organization?.name ?? ""}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<span className="text-muted-foreground text-lg uppercase">
|
||||
{organization?.name.charAt(0)}
|
||||
</span>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<DashboardHeaderTitle className="truncate">
|
||||
{organization?.name}
|
||||
</DashboardHeaderTitle>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<DashboardHeaderDescription>
|
||||
/{organization?.slug}
|
||||
</DashboardHeaderDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Delete id={id} />
|
||||
</DashboardHeader>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import dayjs from "dayjs";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { InvitationStatus, MemberRole } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Avatar, AvatarFallback } from "@turbostarter/ui-web/avatar";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@turbostarter/ui-web/dropdown-menu";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { Invitation } from "@turbostarter/auth";
|
||||
|
||||
export const InvitationActions = ({
|
||||
invitation,
|
||||
}: {
|
||||
invitation: Invitation;
|
||||
}) => {
|
||||
const { t } = useTranslation(["common", "organization"]);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const cancelInvitation = useMutation({
|
||||
...admin.mutations.organizations.invitations.delete,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(
|
||||
admin.queries.organizations.getInvitations({
|
||||
id: invitation.organizationId,
|
||||
}),
|
||||
);
|
||||
toast.success(t("invitations.cancel.success"));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<span className="sr-only">{t("actions")}</span>
|
||||
<Icons.Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
{(() => {
|
||||
const isPending =
|
||||
cancelInvitation.isPending &&
|
||||
cancelInvitation.variables.invitationId === invitation.id;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
cancelInvitation.mutate({
|
||||
id: invitation.organizationId,
|
||||
invitationId: invitation.id,
|
||||
})
|
||||
}
|
||||
disabled={isPending}
|
||||
key={`cancel-${invitation.id}`}
|
||||
>
|
||||
{t("cancel")}
|
||||
{isPending && (
|
||||
<Icons.Loader2 className="ml-auto animate-spin text-current" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})()}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export const useColumns = (): ColumnDef<Invitation>[] => {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
|
||||
return [
|
||||
{
|
||||
id: "email",
|
||||
accessorKey: "email",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("email")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarFallback>
|
||||
<Icons.UserRound className="w-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate font-medium">{row.original.email}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
placeholder: `${t("searchPlaceholder")}`,
|
||||
variant: "text",
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "role",
|
||||
accessorKey: "role",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("role")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <Badge variant="outline">{t(row.original.role)}</Badge>;
|
||||
},
|
||||
meta: {
|
||||
label: t("role"),
|
||||
variant: "multiSelect",
|
||||
options: Object.values(MemberRole).map((role) => ({
|
||||
label: t(role),
|
||||
value: role,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("status")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <Badge variant="secondary">{t(row.original.status)}</Badge>;
|
||||
},
|
||||
meta: {
|
||||
label: t("status"),
|
||||
variant: "multiSelect",
|
||||
options: Object.values(InvitationStatus).map((status) => ({
|
||||
label: t(status),
|
||||
value: status,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "expiresAt",
|
||||
accessorKey: "expiresAt",
|
||||
header: ({ column }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<DataTableColumnHeader column={column} title={t("expiresAt")} />
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn("ml-auto text-right", {
|
||||
"text-destructive": dayjs(row.original.expiresAt).isBefore(
|
||||
dayjs(),
|
||||
),
|
||||
})}
|
||||
>
|
||||
{new Date(row.original.expiresAt).toLocaleString(i18n.language, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("expiresAt"),
|
||||
variant: "dateRange",
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<InvitationActions invitation={row.original} />
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { pickBy } from "@turbostarter/shared/utils";
|
||||
import { DataTable } from "@turbostarter/ui-web/data-table/data-table";
|
||||
import { DataTableSkeleton } from "@turbostarter/ui-web/data-table/data-table-skeleton";
|
||||
import { DataTableToolbar } from "@turbostarter/ui-web/data-table/data-table-toolbar";
|
||||
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
import { useDataTable } from "~/modules/common/hooks/use-data-table";
|
||||
|
||||
import { useColumns } from "./columns";
|
||||
|
||||
interface InvitationsDataTableProps {
|
||||
readonly organizationId: string;
|
||||
}
|
||||
|
||||
export const InvitationsDataTable = ({
|
||||
organizationId,
|
||||
}: InvitationsDataTableProps) => {
|
||||
const columns = useColumns();
|
||||
|
||||
const { table, query } = useDataTable({
|
||||
persistance: "local",
|
||||
columns,
|
||||
initialState: {
|
||||
sorting: [
|
||||
{
|
||||
id: "expiresAt",
|
||||
desc: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
enableRowSelection: false,
|
||||
query: ({ page, perPage, sorting, filters }) =>
|
||||
admin.queries.organizations.getInvitations({
|
||||
id: organizationId,
|
||||
page: page.toString(),
|
||||
perPage: perPage.toString(),
|
||||
sort: JSON.stringify(sorting),
|
||||
...pickBy(filters, Boolean),
|
||||
}),
|
||||
});
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<DataTableSkeleton
|
||||
columnCount={5}
|
||||
filterCount={4}
|
||||
cellWidths={["20rem", "5rem", "5rem", "10rem", "2.5rem"]}
|
||||
shrinkZero
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<DataTableToolbar table={table} />
|
||||
<DataTable table={table} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { MemberRole } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@turbostarter/ui-web/dropdown-menu";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
import { UpdateMemberRoleModal } from "../update-member-role";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { Member } from "@turbostarter/auth";
|
||||
|
||||
export const MemberActions = ({ member }: { member: Member }) => {
|
||||
const { t } = useTranslation(["common", "organization", "auth"]);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const removeMember = useMutation({
|
||||
...admin.mutations.organizations.members.remove,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(
|
||||
admin.queries.organizations.getMembers({
|
||||
id: member.organizationId,
|
||||
}),
|
||||
);
|
||||
await queryClient.invalidateQueries(
|
||||
admin.queries.users.getMemberships({
|
||||
id: member.userId,
|
||||
}),
|
||||
);
|
||||
toast.success(t("members.remove.success"));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<span className="sr-only">{t("actions")}</span>
|
||||
<Icons.Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<UpdateMemberRoleModal member={member} key={`update-role-${member.id}`}>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
{t("updateRole")}
|
||||
</DropdownMenuItem>
|
||||
</UpdateMemberRoleModal>
|
||||
<DropdownMenuSeparator />
|
||||
{(() => {
|
||||
const isPending =
|
||||
removeMember.isPending &&
|
||||
removeMember.variables.memberId === member.id;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
removeMember.mutate({
|
||||
id: member.organizationId,
|
||||
memberId: member.id,
|
||||
})
|
||||
}
|
||||
disabled={isPending}
|
||||
key={`remove-${member.id}`}
|
||||
>
|
||||
{t("remove")}
|
||||
{isPending && (
|
||||
<Icons.Loader2 className="ml-auto animate-spin text-current" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})()}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export const useColumns = (): ColumnDef<Member>[] => {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
return [
|
||||
{
|
||||
id: "q",
|
||||
accessorKey: "q",
|
||||
meta: {
|
||||
placeholder: `${t("searchPlaceholder")}`,
|
||||
variant: "text",
|
||||
},
|
||||
enableHiding: false,
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "user.name",
|
||||
accessorKey: "user.name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("name")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<TurboLink
|
||||
href={pathsConfig.admin.users.user(row.original.userId)}
|
||||
className="group flex items-center gap-3"
|
||||
>
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={row.original.user.image ?? undefined}
|
||||
alt={row.original.user.name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<Icons.UserRound className="w-5" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="group-hover:text-primary truncate font-medium underline underline-offset-4">
|
||||
{row.original.user.name}
|
||||
</span>
|
||||
{row.original.userId === session?.user.id && (
|
||||
<Badge variant="outline">{t("you")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TurboLink>
|
||||
);
|
||||
},
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "user.email",
|
||||
accessorKey: "user.email",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("email")} />
|
||||
),
|
||||
meta: {
|
||||
label: t("email"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "role",
|
||||
accessorKey: "role",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("role")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <Badge variant="outline">{t(row.original.role)}</Badge>;
|
||||
},
|
||||
meta: {
|
||||
label: t("role"),
|
||||
variant: "multiSelect",
|
||||
options: Object.values(MemberRole).map((role) => ({
|
||||
label: t(role),
|
||||
value: role,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<DataTableColumnHeader column={column} title={t("joinedAt")} />
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="ml-auto text-right">
|
||||
{new Date(row.original.createdAt).toLocaleDateString(i18n.language)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("joinedAt"),
|
||||
variant: "dateRange",
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<MemberActions member={row.original} />
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { pickBy } from "@turbostarter/shared/utils";
|
||||
import { DataTable } from "@turbostarter/ui-web/data-table/data-table";
|
||||
import { DataTableSkeleton } from "@turbostarter/ui-web/data-table/data-table-skeleton";
|
||||
import { DataTableToolbar } from "@turbostarter/ui-web/data-table/data-table-toolbar";
|
||||
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
import { useDataTable } from "~/modules/common/hooks/use-data-table";
|
||||
|
||||
import { useColumns } from "./columns";
|
||||
|
||||
interface MembersDataTableProps {
|
||||
readonly organizationId: string;
|
||||
}
|
||||
|
||||
export const MembersDataTable = ({ organizationId }: MembersDataTableProps) => {
|
||||
const columns = useColumns();
|
||||
|
||||
const { table, query } = useDataTable({
|
||||
persistance: "local",
|
||||
columns,
|
||||
initialState: {
|
||||
sorting: [
|
||||
{
|
||||
id: "user.name",
|
||||
desc: false,
|
||||
},
|
||||
],
|
||||
columnVisibility: {
|
||||
q: false,
|
||||
},
|
||||
},
|
||||
enableRowSelection: false,
|
||||
query: ({ page, perPage, sorting, filters }) =>
|
||||
admin.queries.organizations.getMembers({
|
||||
id: organizationId,
|
||||
page: page.toString(),
|
||||
perPage: perPage.toString(),
|
||||
sort: JSON.stringify(sorting),
|
||||
...pickBy(filters, Boolean),
|
||||
}),
|
||||
});
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<DataTableSkeleton
|
||||
columnCount={5}
|
||||
filterCount={3}
|
||||
cellWidths={["20rem", "10rem", "7rem", "4rem", "2.5rem"]}
|
||||
shrinkZero
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<DataTableToolbar table={table} />
|
||||
<DataTable table={table} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { MemberRole, updateMemberSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Modal,
|
||||
ModalTrigger,
|
||||
ModalTitle,
|
||||
ModalHeader,
|
||||
ModalContent,
|
||||
ModalDescription,
|
||||
ModalClose,
|
||||
ModalFooter,
|
||||
} from "@turbostarter/ui-web/modal";
|
||||
import {
|
||||
Select,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectTrigger,
|
||||
} from "@turbostarter/ui-web/select";
|
||||
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
|
||||
import type { Member, UpdateMemberPayload } from "@turbostarter/auth";
|
||||
|
||||
interface UpdateMemberRoleModalProps {
|
||||
readonly member: Member;
|
||||
readonly children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const UpdateMemberRoleModal = ({
|
||||
member,
|
||||
children,
|
||||
}: UpdateMemberRoleModalProps) => {
|
||||
const { t } = useTranslation(["common", "admin", "organization"]);
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const updateMember = useMutation({
|
||||
...admin.mutations.organizations.members.update,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(
|
||||
admin.queries.organizations.getMembers({
|
||||
id: member.organizationId,
|
||||
}),
|
||||
);
|
||||
await queryClient.invalidateQueries(
|
||||
admin.queries.users.getMemberships({
|
||||
id: member.userId,
|
||||
}),
|
||||
);
|
||||
toast.success(t("members.update.role.success"));
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(updateMemberSchema.pick({ role: true })),
|
||||
defaultValues: {
|
||||
role: member.role,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: Pick<UpdateMemberPayload, "role">) => {
|
||||
await updateMember.mutateAsync({
|
||||
id: member.organizationId,
|
||||
memberId: member.id,
|
||||
...data,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<ModalTrigger asChild>{children}</ModalTrigger>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>
|
||||
{t("members.update.role.title", {
|
||||
name: member.user.name,
|
||||
})}
|
||||
</ModalTitle>
|
||||
<ModalDescription>
|
||||
{t("members.update.role.description")}
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem className="px-4 md:px-0">
|
||||
<FormLabel>{t("role")}</FormLabel>
|
||||
<FormControl>
|
||||
<div>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t("role")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(MemberRole).map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{t(role)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("members.update.role.info")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ModalFooter>
|
||||
<ModalClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</ModalClose>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("update")
|
||||
)}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
208
apps/web/src/modules/admin/users/data-table/columns.tsx
Normal file
208
apps/web/src/modules/admin/users/data-table/columns.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { UserRole } from "@turbostarter/auth";
|
||||
import { isKey, useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { User } from "@turbostarter/auth";
|
||||
|
||||
export const useColumns = (): ColumnDef<User>[] => {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
return [
|
||||
{
|
||||
id: "q",
|
||||
accessorKey: "q",
|
||||
meta: {
|
||||
placeholder: `${t("searchPlaceholder")}`,
|
||||
variant: "text",
|
||||
},
|
||||
enableHiding: false,
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("name")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<TurboLink
|
||||
href={pathsConfig.admin.users.user(row.original.id)}
|
||||
className="group flex items-center gap-3"
|
||||
>
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={row.original.image ?? undefined}
|
||||
alt={row.original.name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<Icons.UserRound className="w-5" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="group-hover:text-primary truncate font-medium underline underline-offset-4">
|
||||
{row.original.name}
|
||||
</span>
|
||||
{row.original.id === session?.user.id && (
|
||||
<Badge variant="outline">{t("you")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TurboLink>
|
||||
);
|
||||
},
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
accessorKey: "email",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("email")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{row.original.email}
|
||||
|
||||
<Badge
|
||||
className={cn({
|
||||
"bg-success/15 text-success hover:bg-success/25":
|
||||
row.original.emailVerified,
|
||||
"bg-destructive/15 text-destructive hover:bg-destructive/25":
|
||||
!row.original.emailVerified,
|
||||
})}
|
||||
>
|
||||
{row.original.emailVerified ? t("verified") : t("unverified")}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("email"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "role",
|
||||
accessorKey: "role",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("role")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.role) {
|
||||
return <Badge variant="outline">{t(UserRole.USER)}</Badge>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{row.original.role.split(",").map((role) => (
|
||||
<Badge variant="outline" key={role}>
|
||||
{isKey(role, i18n, "common") ? t(role) : role}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("role"),
|
||||
variant: "multiSelect",
|
||||
options: Object.values(UserRole).map((role) => ({
|
||||
label: t(role),
|
||||
value: role,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "twoFactorEnabled",
|
||||
accessorKey: "twoFactorEnabled",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={"2FA"} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return row.original.twoFactorEnabled ? (
|
||||
<>
|
||||
<span className="sr-only">{t("enabled")}</span>
|
||||
<Icons.Check className="size-4" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="sr-only">{t("disabled")}</span>
|
||||
<Icons.X className="size-4" />
|
||||
</>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: "2FA",
|
||||
variant: "multiSelect",
|
||||
options: [
|
||||
{ label: t("enabled"), value: "true" },
|
||||
{ label: t("disabled"), value: "false" },
|
||||
],
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "banned",
|
||||
accessorKey: "banned",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("banned")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return row.original.banned ? (
|
||||
<>
|
||||
<span className="sr-only">{t("banned")}</span>
|
||||
<Icons.Check className="size-4" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="sr-only">{t("notBanned")}</span>
|
||||
<Icons.X className="size-4" />
|
||||
</>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("banned"),
|
||||
variant: "multiSelect",
|
||||
options: [
|
||||
{ label: t("banned"), value: "true" },
|
||||
{ label: t("notBanned"), value: "false" },
|
||||
],
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<DataTableColumnHeader column={column} title={t("createdAt")} />
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="ml-auto text-right">
|
||||
{row.original.createdAt.toLocaleDateString(i18n.language)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("createdAt"),
|
||||
variant: "dateRange",
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
|
||||
import { DataTable } from "@turbostarter/ui-web/data-table/data-table";
|
||||
import { DataTableToolbar } from "@turbostarter/ui-web/data-table/data-table-toolbar";
|
||||
|
||||
import { useDataTable } from "~/modules/common/hooks/use-data-table";
|
||||
|
||||
import { useColumns } from "./columns";
|
||||
|
||||
import type { GetUsersResponse } from "@turbostarter/api/schema";
|
||||
|
||||
interface UsersDataTableProps {
|
||||
readonly promise: Promise<Awaited<GetUsersResponse>>;
|
||||
readonly perPage: number;
|
||||
}
|
||||
|
||||
export const UsersDataTable = ({ promise, perPage }: UsersDataTableProps) => {
|
||||
const columns = useColumns();
|
||||
const { data, total } = use(promise);
|
||||
|
||||
const { table } = useDataTable({
|
||||
persistance: "searchParams",
|
||||
data,
|
||||
columns,
|
||||
pageCount: Math.ceil(total / perPage),
|
||||
initialState: {
|
||||
sorting: [
|
||||
{
|
||||
id: "name",
|
||||
desc: false,
|
||||
},
|
||||
],
|
||||
columnVisibility: {
|
||||
q: false,
|
||||
},
|
||||
},
|
||||
shallow: false,
|
||||
clearOnDefault: true,
|
||||
enableRowSelection: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<DataTableToolbar table={table} />
|
||||
<DataTable table={table} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { pickBy } from "@turbostarter/shared/utils";
|
||||
import { DataTable } from "@turbostarter/ui-web/data-table/data-table";
|
||||
import { DataTableSkeleton } from "@turbostarter/ui-web/data-table/data-table-skeleton";
|
||||
import { DataTableToolbar } from "@turbostarter/ui-web/data-table/data-table-toolbar";
|
||||
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
import { useDataTable } from "~/modules/common/hooks/use-data-table";
|
||||
|
||||
import { useColumns } from "./columns";
|
||||
|
||||
interface AccountsDataTableProps {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export const AccountsDataTable = ({ id }: AccountsDataTableProps) => {
|
||||
const columns = useColumns();
|
||||
|
||||
const { table, query } = useDataTable({
|
||||
persistance: "local",
|
||||
columns,
|
||||
initialState: {
|
||||
sorting: [
|
||||
{
|
||||
id: "providerId",
|
||||
desc: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
enableRowSelection: false,
|
||||
query: ({ page, perPage, sorting, filters }) =>
|
||||
admin.queries.users.getAccounts({
|
||||
id,
|
||||
page: page.toString(),
|
||||
perPage: perPage.toString(),
|
||||
sort: JSON.stringify(sorting),
|
||||
...pickBy(filters, Boolean),
|
||||
}),
|
||||
});
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<DataTableSkeleton
|
||||
columnCount={3}
|
||||
filterCount={3}
|
||||
cellWidths={["15rem", "10rem", "10rem"]}
|
||||
rowCount={3}
|
||||
shrinkZero
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<DataTableToolbar table={table} />
|
||||
<DataTable table={table} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { SocialProvider } from "@turbostarter/auth";
|
||||
import { isKey, useTranslation } from "@turbostarter/i18n";
|
||||
import { capitalize } from "@turbostarter/shared/utils";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
} from "@turbostarter/ui-web/dropdown-menu";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
import { SetPasswordModal } from "~/modules/admin/users/user/accounts/set-password";
|
||||
import { SocialIcons } from "~/modules/auth/form/social-providers";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { GetUserAccountsResponse } from "@turbostarter/api/schema";
|
||||
|
||||
export const AccountActions = ({
|
||||
account,
|
||||
}: {
|
||||
account: GetUserAccountsResponse["data"][number];
|
||||
}) => {
|
||||
const { t } = useTranslation(["common", "auth", "admin"]);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const deleteAccount = useMutation({
|
||||
...admin.mutations.users.accounts.delete,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(
|
||||
admin.queries.users.getAccounts({
|
||||
id: account.userId,
|
||||
}),
|
||||
);
|
||||
toast.success(t("users.user.accounts.delete.success"));
|
||||
},
|
||||
});
|
||||
|
||||
const groups = [
|
||||
account.providerId === "credential"
|
||||
? [
|
||||
<SetPasswordModal id={account.userId} key="set-password">
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
{t("account.password.update.cta")}
|
||||
</DropdownMenuItem>
|
||||
</SetPasswordModal>,
|
||||
]
|
||||
: null,
|
||||
[
|
||||
[
|
||||
(() => {
|
||||
const isPending =
|
||||
deleteAccount.isPending &&
|
||||
deleteAccount.variables.accountId === account.id;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
key="delete"
|
||||
onSelect={() =>
|
||||
deleteAccount.mutate({
|
||||
accountId: account.id,
|
||||
id: account.userId,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("delete")}
|
||||
{isPending && (
|
||||
<Icons.Loader2 className="ml-auto animate-spin text-current" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})(),
|
||||
],
|
||||
],
|
||||
].filter((group) => group?.filter(Boolean).length);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<span className="sr-only">{t("actions")}</span>
|
||||
<Icons.Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
{groups.flatMap((group, idx, array) =>
|
||||
idx < array.length - 1
|
||||
? [group, <DropdownMenuSeparator key={`sep-${idx}`} />]
|
||||
: [group],
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export const useColumns = (): ColumnDef<
|
||||
GetUserAccountsResponse["data"][number]
|
||||
>[] => {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
|
||||
return [
|
||||
{
|
||||
id: "providerId",
|
||||
accessorKey: "providerId",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("provider")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{row.original.providerId in SocialIcons ? (
|
||||
(() => {
|
||||
const Icon =
|
||||
SocialIcons[row.original.providerId as SocialProvider];
|
||||
return (
|
||||
<>
|
||||
<Icon className="size-4" />
|
||||
<span className="capitalize">
|
||||
{row.original.providerId}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<>
|
||||
<Icons.Lock className="size-4" />
|
||||
<span>{t("credential")}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("provider"),
|
||||
variant: "multiSelect",
|
||||
options: ["credential", ...Object.values(SocialProvider)].map(
|
||||
(provider) => ({
|
||||
label: isKey(provider, i18n, "common")
|
||||
? t(provider)
|
||||
: capitalize(provider),
|
||||
value: provider,
|
||||
}),
|
||||
),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<DataTableColumnHeader column={column} title={t("createdAt")} />
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="ml-auto text-right">
|
||||
{new Date(row.original.createdAt).toLocaleString(i18n.language)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("createdAt"),
|
||||
variant: "dateRange",
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "updatedAt",
|
||||
accessorKey: "updatedAt",
|
||||
header: ({ column }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<DataTableColumnHeader column={column} title={t("updatedAt")} />
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="ml-auto text-right">
|
||||
{new Date(row.original.createdAt).toLocaleString(i18n.language)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("updatedAt"),
|
||||
variant: "dateRange",
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<AccountActions account={row.original} />
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
};
|
||||
119
apps/web/src/modules/admin/users/user/accounts/set-password.tsx
Normal file
119
apps/web/src/modules/admin/users/user/accounts/set-password.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { passwordSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { PasswordInput } from "@turbostarter/ui-web/input";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalDescription,
|
||||
ModalFooter,
|
||||
ModalClose,
|
||||
ModalTrigger,
|
||||
} from "@turbostarter/ui-web/modal";
|
||||
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
|
||||
interface SetPasswordModalProps {
|
||||
readonly id: string;
|
||||
readonly children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SetPasswordModal = ({ id, children }: SetPasswordModalProps) => {
|
||||
const { t } = useTranslation(["common", "auth", "admin"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data: user } = useQuery(admin.queries.users.get({ id }));
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(passwordSchema),
|
||||
});
|
||||
|
||||
const setPassword = useMutation({
|
||||
...admin.mutations.users.setPassword,
|
||||
onSuccess: () => {
|
||||
toast.success(t("users.user.accounts.password.update.success"));
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<ModalTrigger asChild>{children}</ModalTrigger>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>
|
||||
{t("users.user.accounts.password.update.title", {
|
||||
name: user?.name,
|
||||
})}
|
||||
</ModalTitle>
|
||||
<ModalDescription>
|
||||
{t("users.user.accounts.password.update.description")}
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) =>
|
||||
setPassword.mutateAsync({
|
||||
userId: id,
|
||||
newPassword: data.password,
|
||||
}),
|
||||
)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem className="px-4 md:px-0">
|
||||
<FormLabel>{t("newPassword")}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("users.user.accounts.password.update.info")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<ModalFooter>
|
||||
<ModalClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</ModalClose>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("update")
|
||||
)}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
389
apps/web/src/modules/admin/users/user/actions/ban.tsx
Normal file
389
apps/web/src/modules/admin/users/user/actions/ban.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import {
|
||||
useMutation,
|
||||
useMutationState,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import dayjs from "dayjs";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { banUserSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Calendar } from "@turbostarter/ui-web/calendar";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Modal,
|
||||
ModalTrigger,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalDescription,
|
||||
ModalFooter,
|
||||
ModalClose,
|
||||
ModalBody,
|
||||
} from "@turbostarter/ui-web/modal";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@turbostarter/ui-web/popover";
|
||||
import { ScrollArea, ScrollBar } from "@turbostarter/ui-web/scroll-area";
|
||||
import { Textarea } from "@turbostarter/ui-web/textarea";
|
||||
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
|
||||
import type { BanUserPayload } from "@turbostarter/auth";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
interface BanProps {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export const Ban = ({ id }: BanProps) => {
|
||||
const { data: user } = useQuery(admin.queries.users.get({ id }));
|
||||
|
||||
if (user?.banned) {
|
||||
return <Unban userId={id} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BanModal id={id}>
|
||||
<BanTrigger userId={id} />
|
||||
</BanModal>
|
||||
);
|
||||
};
|
||||
|
||||
const Unban = ({
|
||||
userId,
|
||||
...props
|
||||
}: ComponentProps<typeof Button> & { userId: string }) => {
|
||||
const { t } = useTranslation(["common", "admin"]);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const unban = useMutation({
|
||||
...admin.mutations.users.unban,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(
|
||||
admin.queries.users.get({ id: userId }),
|
||||
);
|
||||
toast.success(t("users.user.unban.success"));
|
||||
},
|
||||
});
|
||||
|
||||
const isPending = unban.isPending && unban.variables.userId === userId;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isPending}
|
||||
onClick={() => unban.mutate({ userId })}
|
||||
{...props}
|
||||
>
|
||||
{isPending ? <Icons.Loader2 className="animate-spin" /> : <Icons.Undo2 />}
|
||||
{t("unban")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const BanTrigger = ({
|
||||
userId,
|
||||
...props
|
||||
}: ComponentProps<typeof Button> & { userId: string }) => {
|
||||
const { t } = useTranslation("common");
|
||||
const [mutation] = useMutationState({
|
||||
filters: {
|
||||
mutationKey: admin.mutations.users.ban.mutationKey,
|
||||
},
|
||||
select: (mutation) => ({
|
||||
status: mutation.state.status,
|
||||
variables: mutation.state.variables as { userId: string },
|
||||
}),
|
||||
});
|
||||
|
||||
const isPending =
|
||||
mutation?.status === "pending" && mutation.variables.userId === userId;
|
||||
|
||||
return (
|
||||
<Button variant="outline" disabled={isPending} {...props}>
|
||||
{isPending ? <Icons.Loader2 className="animate-spin" /> : <Icons.Ban />}
|
||||
{t("ban")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const BanModal = ({
|
||||
id,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation(["common", "admin"]);
|
||||
const { data: user } = useQuery(admin.queries.users.get({ id }));
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(banUserSchema),
|
||||
});
|
||||
|
||||
const ban = useMutation({
|
||||
...admin.mutations.users.ban,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(admin.queries.users.get({ id }));
|
||||
toast.success(t("users.user.ban.success"));
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = ({ expiresIn, reason }: BanUserPayload) => {
|
||||
ban.mutate({
|
||||
userId: id,
|
||||
banReason: reason,
|
||||
banExpiresIn: expiresIn
|
||||
? Math.max(0, dayjs(expiresIn).diff(dayjs(), "second"))
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const isPending = ban.isPending && ban.variables.userId === id;
|
||||
|
||||
function handleDateSelect(date: Date | undefined) {
|
||||
if (date) {
|
||||
form.setValue("expiresIn", date);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTimeChange(type: "hour" | "minute" | "ampm", value: string) {
|
||||
const currentDate = form.getValues("expiresIn") ?? new Date();
|
||||
const newDate = new Date(currentDate);
|
||||
|
||||
if (type === "hour") {
|
||||
const hour = parseInt(value, 10);
|
||||
newDate.setHours(newDate.getHours() >= 12 ? hour + 12 : hour);
|
||||
} else if (type === "minute") {
|
||||
newDate.setMinutes(parseInt(value, 10));
|
||||
} else {
|
||||
const hours = newDate.getHours();
|
||||
if (value === "AM" && hours >= 12) {
|
||||
newDate.setHours(hours - 12);
|
||||
} else if (value === "PM" && hours < 12) {
|
||||
newDate.setHours(hours + 12);
|
||||
}
|
||||
}
|
||||
|
||||
form.setValue("expiresIn", newDate);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<ModalTrigger asChild>{children}</ModalTrigger>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>
|
||||
{t("ban")} {user?.name}
|
||||
</ModalTitle>
|
||||
<ModalDescription>{t("users.user.ban.description")}</ModalDescription>
|
||||
</ModalHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="md:space-y-6">
|
||||
<ModalBody>
|
||||
<div className="flex flex-col gap-4 py-2 md:py-0">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="reason"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex justify-between">
|
||||
<FormLabel>{t("reason")}</FormLabel>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{field.value?.length ?? 0}/1000
|
||||
</span>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Textarea {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("users.user.ban.form.reason.info")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expiresIn"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>{t("expiresIn")}</FormLabel>
|
||||
<div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-full pl-3 text-left font-normal",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? field.value.toLocaleString(i18n.language)
|
||||
: t("never")}
|
||||
<Icons.Calendar className="ml-auto h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<div className="sm:flex">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={field.value}
|
||||
onSelect={handleDateSelect}
|
||||
disabled={(date) =>
|
||||
dayjs(date).isBefore(
|
||||
dayjs().add(1, "day").startOf("day"),
|
||||
)
|
||||
}
|
||||
captionLayout="dropdown"
|
||||
/>
|
||||
<div className="flex flex-col divide-y sm:h-[300px] sm:flex-row sm:divide-x sm:divide-y-0">
|
||||
<ScrollArea className="w-64 sm:w-auto">
|
||||
<div className="flex p-2 sm:flex-col">
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1)
|
||||
.reverse()
|
||||
.map((hour) => (
|
||||
<Button
|
||||
key={hour}
|
||||
type="button"
|
||||
size="icon"
|
||||
variant={
|
||||
field.value &&
|
||||
field.value.getHours() % 12 ===
|
||||
hour % 12
|
||||
? "default"
|
||||
: "ghost"
|
||||
}
|
||||
className="aspect-square shrink-0 sm:w-full"
|
||||
onClick={() =>
|
||||
handleTimeChange(
|
||||
"hour",
|
||||
hour.toString(),
|
||||
)
|
||||
}
|
||||
>
|
||||
{hour}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar
|
||||
orientation="horizontal"
|
||||
className="sm:hidden"
|
||||
/>
|
||||
</ScrollArea>
|
||||
<ScrollArea className="w-64 sm:w-auto">
|
||||
<div className="flex p-2 sm:flex-col">
|
||||
{Array.from(
|
||||
{ length: 12 },
|
||||
(_, i) => i * 5,
|
||||
).map((minute) => (
|
||||
<Button
|
||||
key={minute}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant={
|
||||
field.value &&
|
||||
field.value.getMinutes() === minute
|
||||
? "default"
|
||||
: "ghost"
|
||||
}
|
||||
className="aspect-square shrink-0 sm:w-full"
|
||||
onClick={() =>
|
||||
handleTimeChange(
|
||||
"minute",
|
||||
minute.toString(),
|
||||
)
|
||||
}
|
||||
>
|
||||
{minute.toString().padStart(2, "0")}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar
|
||||
orientation="horizontal"
|
||||
className="sm:hidden"
|
||||
/>
|
||||
</ScrollArea>
|
||||
<ScrollArea className="">
|
||||
<div className="flex p-2 sm:flex-col">
|
||||
{["AM", "PM"].map((ampm) => (
|
||||
<Button
|
||||
key={ampm}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant={
|
||||
field.value &&
|
||||
((ampm === "AM" &&
|
||||
field.value.getHours() < 12) ||
|
||||
(ampm === "PM" &&
|
||||
field.value.getHours() >= 12))
|
||||
? "default"
|
||||
: "ghost"
|
||||
}
|
||||
className="aspect-square shrink-0 sm:w-full"
|
||||
onClick={() =>
|
||||
handleTimeChange("ampm", ampm)
|
||||
}
|
||||
>
|
||||
{ampm}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<FormDescription>
|
||||
{t("users.user.ban.form.expiresIn.info")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<ModalClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</ModalClose>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("ban")
|
||||
)}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
113
apps/web/src/modules/admin/users/user/actions/delete.tsx
Normal file
113
apps/web/src/modules/admin/users/user/actions/delete.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useMutation, useMutationState, useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Modal,
|
||||
ModalTrigger,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalDescription,
|
||||
ModalFooter,
|
||||
ModalClose,
|
||||
} from "@turbostarter/ui-web/modal";
|
||||
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
|
||||
interface DeleteProps {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export const Delete = ({ id }: DeleteProps) => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
const [mutation] = useMutationState({
|
||||
filters: {
|
||||
mutationKey: admin.mutations.users.delete.mutationKey,
|
||||
},
|
||||
select: (mutation) => ({
|
||||
status: mutation.state.status,
|
||||
variables: mutation.state.variables as { userId: string },
|
||||
}),
|
||||
});
|
||||
|
||||
const isPending =
|
||||
mutation?.status === "pending" && mutation.variables.userId === id;
|
||||
|
||||
return (
|
||||
<ConfirmModal userId={id}>
|
||||
<Button variant="destructive" disabled={isPending}>
|
||||
{isPending ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<Icons.Trash />
|
||||
)}
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</ConfirmModal>
|
||||
);
|
||||
};
|
||||
|
||||
const ConfirmModal = ({
|
||||
children,
|
||||
userId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
userId: string;
|
||||
}) => {
|
||||
const { t } = useTranslation(["common", "admin"]);
|
||||
const router = useRouter();
|
||||
|
||||
const { data: user } = useQuery(admin.queries.users.get({ id: userId }));
|
||||
|
||||
const deleteUser = useMutation({
|
||||
...admin.mutations.users.delete,
|
||||
onSuccess: () => {
|
||||
toast.success(t("users.user.delete.success"));
|
||||
router.back();
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteUser.mutate({ userId });
|
||||
};
|
||||
|
||||
const isPending =
|
||||
deleteUser.isPending && deleteUser.variables.userId === userId;
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<ModalTrigger asChild>{children}</ModalTrigger>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>
|
||||
{t("users.user.delete.title", { name: user?.name })}
|
||||
</ModalTitle>
|
||||
<ModalDescription className="whitespace-pre-line">
|
||||
{t("users.user.delete.disclaimer")}
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
<ModalFooter>
|
||||
<ModalClose asChild>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
variant="destructive"
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("delete")
|
||||
)}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
|
||||
interface ImpersonateProps {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export const Impersonate = ({ id }: ImpersonateProps) => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
const impersonate = useMutation({
|
||||
...admin.mutations.users.impersonate,
|
||||
onSuccess: () => {
|
||||
window.open(pathsConfig.index, "_blank");
|
||||
},
|
||||
});
|
||||
|
||||
const isPending =
|
||||
impersonate.isPending && impersonate.variables.userId === id;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => impersonate.mutate({ userId: id })}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<Icons.VenetianMask />
|
||||
)}
|
||||
{t("impersonate")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
264
apps/web/src/modules/admin/users/user/details.tsx
Normal file
264
apps/web/src/modules/admin/users/user/details.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { updateUserSchema, UserRole } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { useDebounceCallback } from "@turbostarter/shared/hooks";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Input } from "@turbostarter/ui-web/input";
|
||||
import {
|
||||
Select,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectPortal,
|
||||
} from "@turbostarter/ui-web/select";
|
||||
|
||||
import {
|
||||
DetailsList,
|
||||
DetailsListItem,
|
||||
} from "~/modules/admin/layout/details-list";
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
|
||||
const Role = ({ id }: { id: string }) => {
|
||||
const { t } = useTranslation(["common", "admin"]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { data: user } = useQuery(admin.queries.users.get({ id }));
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(updateUserSchema.pick({ role: true })),
|
||||
defaultValues: {
|
||||
role: user?.role as UserRole,
|
||||
},
|
||||
});
|
||||
|
||||
const updateUser = useMutation({
|
||||
...admin.mutations.users.update,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(admin.queries.users.get({ id }));
|
||||
toast.success(t("users.user.details.role.update.success"));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FormLabel>{t("role")}</FormLabel>
|
||||
{form.formState.isSubmitting && (
|
||||
<Icons.Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<div>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
void form.handleSubmit((data) =>
|
||||
updateUser.mutateAsync({
|
||||
data,
|
||||
userId: id,
|
||||
}),
|
||||
)();
|
||||
}}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t("role")} />
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
<SelectContent>
|
||||
{Object.values(UserRole).map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{t(role)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</Select>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
const Name = ({ id }: { id: string }) => {
|
||||
const { t } = useTranslation(["common", "admin"]);
|
||||
const queryClient = useQueryClient();
|
||||
const { data: user } = useQuery(admin.queries.users.get({ id }));
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(updateUserSchema.pick({ name: true })),
|
||||
defaultValues: {
|
||||
name: user?.name,
|
||||
},
|
||||
});
|
||||
|
||||
const updateUser = useMutation({
|
||||
...admin.mutations.users.update,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(admin.queries.users.get({ id }));
|
||||
toast.success(t("users.user.details.name.update.success"));
|
||||
},
|
||||
});
|
||||
|
||||
const debouncedOnSubmit = useDebounceCallback(
|
||||
form.handleSubmit((data) =>
|
||||
updateUser.mutateAsync({
|
||||
data,
|
||||
userId: id,
|
||||
}),
|
||||
),
|
||||
2000,
|
||||
{
|
||||
trailing: true,
|
||||
leading: false,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FormLabel>{t("name")}</FormLabel>
|
||||
{form.formState.isSubmitting && (
|
||||
<Icons.Loader2 className="size-3 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value);
|
||||
void debouncedOnSubmit();
|
||||
}}
|
||||
disabled={form.formState.isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
interface UserDetailsProps {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export const UserDetails = ({ id }: UserDetailsProps) => {
|
||||
const { t, i18n } = useTranslation(["common", "auth"]);
|
||||
const { data: user } = useQuery(admin.queries.users.get({ id }));
|
||||
|
||||
const details = [
|
||||
{
|
||||
id: "name",
|
||||
component: <Name id={id} />,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: "role",
|
||||
component: <Role id={id} />,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: "twoFactorEnabled",
|
||||
component: (
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<span className="text-sm font-medium">{t("two-factor")}</span>
|
||||
<span>{user?.twoFactorEnabled ? t("enabled") : t("disabled")}</span>
|
||||
</div>
|
||||
),
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
component: (
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<span className="text-sm font-medium">{t("createdAt")}</span>
|
||||
<span>{user?.createdAt.toLocaleString(i18n.language)}</span>
|
||||
</div>
|
||||
),
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: "updatedAt",
|
||||
component: (
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<span className="text-sm font-medium">{t("updatedAt")}</span>
|
||||
<span>{user?.updatedAt.toLocaleString(i18n.language)}</span>
|
||||
</div>
|
||||
),
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
id: "banExpires",
|
||||
component: (
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<span className="text-sm font-medium">{t("banExpiresIn")}</span>
|
||||
{user?.banExpires ? (
|
||||
<span>{user.banExpires.toLocaleString(i18n.language)}</span>
|
||||
) : (
|
||||
<span>{t("never")}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
visible: !!user?.banned,
|
||||
},
|
||||
{
|
||||
id: "banReason",
|
||||
component: (
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<span className="text-sm font-medium">{t("banReason")}</span>
|
||||
<p>{user?.banReason}</p>
|
||||
</div>
|
||||
),
|
||||
visible: !!user?.banned,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="@container/details w-full overflow-hidden">
|
||||
<DetailsList>
|
||||
{details
|
||||
.filter((detail) => detail.visible)
|
||||
.map((detail) => (
|
||||
<DetailsListItem key={detail.id}>
|
||||
{detail.component}
|
||||
</DetailsListItem>
|
||||
))}
|
||||
</DetailsList>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
83
apps/web/src/modules/admin/users/user/header.tsx
Normal file
83
apps/web/src/modules/admin/users/user/header.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
import {
|
||||
DashboardHeader,
|
||||
DashboardHeaderTitle,
|
||||
DashboardHeaderDescription,
|
||||
} from "~/modules/common/layout/dashboard/header";
|
||||
|
||||
import { Ban } from "./actions/ban";
|
||||
import { Delete } from "./actions/delete";
|
||||
import { Impersonate } from "./actions/impersonate";
|
||||
|
||||
interface UserHeaderProps {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export const UserHeader = ({ id }: UserHeaderProps) => {
|
||||
const { t } = useTranslation("common");
|
||||
const session = authClient.useSession();
|
||||
const { data: user } = useQuery(admin.queries.users.get({ id }));
|
||||
|
||||
return (
|
||||
<DashboardHeader>
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<Avatar className="size-12">
|
||||
<AvatarImage src={user?.image ?? undefined} alt={user?.name ?? ""} />
|
||||
<AvatarFallback>
|
||||
<Icons.UserRound className="w-7" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<DashboardHeaderTitle className="truncate">
|
||||
{user?.name}
|
||||
</DashboardHeaderTitle>
|
||||
{user?.isAnonymous && (
|
||||
<Badge variant="outline">{t("anonymous")}</Badge>
|
||||
)}
|
||||
{user?.id === session.data?.user.id && (
|
||||
<Badge variant="outline">{t("you")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<DashboardHeaderDescription>
|
||||
{user?.email}
|
||||
</DashboardHeaderDescription>
|
||||
<Badge
|
||||
className={cn({
|
||||
"bg-success/15 text-success hover:bg-success/25":
|
||||
user?.emailVerified,
|
||||
"bg-destructive/15 text-destructive hover:bg-destructive/25":
|
||||
!user?.emailVerified,
|
||||
})}
|
||||
>
|
||||
{user?.emailVerified ? t("verified") : t("unverified")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Impersonate id={id} />
|
||||
<Ban id={id} />
|
||||
<Delete id={id} />
|
||||
</div>
|
||||
</DashboardHeader>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
|
||||
export const ImpersonatingBanner = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const session = authClient.useSession();
|
||||
const router = useRouter();
|
||||
|
||||
const userId = session.data?.user.id;
|
||||
const isImpersonating = !!session.data?.session.impersonatedBy;
|
||||
|
||||
const stop = useMutation({
|
||||
...admin.mutations.users.stopImpersonating,
|
||||
onSuccess: async () => {
|
||||
await session.refetch();
|
||||
router.replace(
|
||||
userId
|
||||
? pathsConfig.admin.users.user(userId)
|
||||
: pathsConfig.admin.users.index,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isImpersonating) {
|
||||
document.body.style.setProperty("--banner-height", "2.5rem");
|
||||
document.body.style.paddingTop = "2.5rem";
|
||||
} else {
|
||||
document.body.style.setProperty("--banner-height", "0");
|
||||
document.body.style.paddingTop = "0";
|
||||
}
|
||||
}, [isImpersonating]);
|
||||
|
||||
if (!isImpersonating) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-primary fixed top-0 left-0 z-50 flex h-10 w-full items-center justify-center gap-2 px-4">
|
||||
<span className="text-primary-foreground truncate text-sm">
|
||||
{t("impersonating")}{" "}
|
||||
<span className="font-medium">{session.data?.user.email}</span>
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => stop.mutate()}
|
||||
disabled={stop.isPending}
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{stop.isPending ? (
|
||||
<Icons.Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
"End"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { InvitationStatus, MemberRole } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { InvitationActions } from "~/modules/admin/organizations/organization/invitations/data-table/columns";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { GetUserInvitationsResponse } from "@turbostarter/api/schema";
|
||||
|
||||
export const useColumns = (): ColumnDef<
|
||||
GetUserInvitationsResponse["data"][number]
|
||||
>[] => {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
|
||||
return [
|
||||
{
|
||||
id: "organization.name",
|
||||
accessorKey: "organization.name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("organization")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<TurboLink
|
||||
href={pathsConfig.admin.organizations.organization(
|
||||
row.original.organization.id,
|
||||
)}
|
||||
className="group flex items-center gap-3"
|
||||
>
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={row.original.organization.logo ?? undefined}
|
||||
alt={row.original.organization.name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<span className="text-muted-foreground text-sm uppercase">
|
||||
{row.original.organization.name.charAt(0)}
|
||||
</span>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="group-hover:text-primary truncate font-medium underline underline-offset-4">
|
||||
{row.original.organization.name}
|
||||
</span>
|
||||
</TurboLink>
|
||||
);
|
||||
},
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "role",
|
||||
accessorKey: "role",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("role")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <Badge variant="outline">{t(row.original.role)}</Badge>;
|
||||
},
|
||||
meta: {
|
||||
label: t("role"),
|
||||
variant: "multiSelect",
|
||||
options: Object.values(MemberRole).map((role) => ({
|
||||
label: t(role),
|
||||
value: role,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("status")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <Badge variant="secondary">{t(row.original.status)}</Badge>;
|
||||
},
|
||||
meta: {
|
||||
label: t("status"),
|
||||
variant: "multiSelect",
|
||||
options: Object.values(InvitationStatus).map((status) => ({
|
||||
label: t(status),
|
||||
value: status,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "expiresAt",
|
||||
accessorKey: "expiresAt",
|
||||
header: ({ column }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<DataTableColumnHeader column={column} title={t("expiresAt")} />
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn("ml-auto text-right", {
|
||||
"text-destructive": dayjs(row.original.expiresAt).isBefore(
|
||||
dayjs(),
|
||||
),
|
||||
})}
|
||||
>
|
||||
{new Date(row.original.expiresAt).toLocaleString(i18n.language, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("expiresAt"),
|
||||
variant: "dateRange",
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<InvitationActions invitation={row.original} />
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { pickBy } from "@turbostarter/shared/utils";
|
||||
import { DataTable } from "@turbostarter/ui-web/data-table/data-table";
|
||||
import { DataTableSkeleton } from "@turbostarter/ui-web/data-table/data-table-skeleton";
|
||||
import { DataTableToolbar } from "@turbostarter/ui-web/data-table/data-table-toolbar";
|
||||
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
import { useDataTable } from "~/modules/common/hooks/use-data-table";
|
||||
|
||||
import { useColumns } from "./columns";
|
||||
|
||||
interface InvitationsDataTableProps {
|
||||
readonly userId: string;
|
||||
}
|
||||
|
||||
export const InvitationsDataTable = ({ userId }: InvitationsDataTableProps) => {
|
||||
const columns = useColumns();
|
||||
|
||||
const { table, query } = useDataTable({
|
||||
persistance: "local",
|
||||
columns,
|
||||
initialState: {
|
||||
sorting: [
|
||||
{
|
||||
id: "organization.name",
|
||||
desc: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
enableRowSelection: false,
|
||||
query: ({ page, perPage, sorting, filters }) =>
|
||||
admin.queries.users.getInvitations({
|
||||
id: userId,
|
||||
page: page.toString(),
|
||||
perPage: perPage.toString(),
|
||||
sort: JSON.stringify(sorting),
|
||||
...pickBy(filters, Boolean),
|
||||
}),
|
||||
});
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<DataTableSkeleton
|
||||
columnCount={5}
|
||||
filterCount={4}
|
||||
cellWidths={["20rem", "5rem", "5rem", "10rem", "2.5rem"]}
|
||||
shrinkZero
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<DataTableToolbar table={table} />
|
||||
<DataTable table={table} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { MemberRole } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { MemberActions } from "~/modules/admin/organizations/organization/members/data-table/columns";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { GetUserMembershipsResponse } from "@turbostarter/api/schema";
|
||||
|
||||
export const useColumns = (): ColumnDef<
|
||||
GetUserMembershipsResponse["data"][number]
|
||||
>[] => {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
|
||||
return [
|
||||
{
|
||||
id: "organization.name",
|
||||
accessorKey: "organization.name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("organization")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<TurboLink
|
||||
href={pathsConfig.admin.organizations.organization(
|
||||
row.original.organizationId,
|
||||
)}
|
||||
className="group flex items-center gap-3"
|
||||
>
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src={row.original.organization.logo ?? undefined}
|
||||
alt={row.original.organization.name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<span className="text-muted-foreground text-sm uppercase">
|
||||
{row.original.organization.name.charAt(0)}
|
||||
</span>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="group-hover:text-primary truncate font-medium underline underline-offset-4">
|
||||
{row.original.organization.name}
|
||||
</span>
|
||||
</TurboLink>
|
||||
);
|
||||
},
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: "role",
|
||||
accessorKey: "role",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("role")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <Badge variant="outline">{t(row.original.role)}</Badge>;
|
||||
},
|
||||
meta: {
|
||||
label: t("role"),
|
||||
variant: "multiSelect",
|
||||
options: Object.values(MemberRole).map((role) => ({
|
||||
label: t(role),
|
||||
value: role,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<DataTableColumnHeader column={column} title={t("joinedAt")} />
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="ml-auto text-right">
|
||||
{new Date(row.original.createdAt).toLocaleDateString(i18n.language)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("joinedAt"),
|
||||
variant: "dateRange",
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<MemberActions member={row.original} />
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { pickBy } from "@turbostarter/shared/utils";
|
||||
import { DataTable } from "@turbostarter/ui-web/data-table/data-table";
|
||||
import { DataTableSkeleton } from "@turbostarter/ui-web/data-table/data-table-skeleton";
|
||||
import { DataTableToolbar } from "@turbostarter/ui-web/data-table/data-table-toolbar";
|
||||
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
import { useDataTable } from "~/modules/common/hooks/use-data-table";
|
||||
|
||||
import { useColumns } from "./columns";
|
||||
|
||||
interface MembershipsDataTableProps {
|
||||
readonly userId: string;
|
||||
}
|
||||
|
||||
export const MembershipsDataTable = ({ userId }: MembershipsDataTableProps) => {
|
||||
const columns = useColumns();
|
||||
|
||||
const { table, query } = useDataTable({
|
||||
persistance: "local",
|
||||
columns,
|
||||
initialState: {
|
||||
sorting: [
|
||||
{
|
||||
id: "organization.name",
|
||||
desc: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
enableRowSelection: false,
|
||||
query: ({ page, perPage, sorting, filters }) =>
|
||||
admin.queries.users.getMemberships({
|
||||
id: userId,
|
||||
page: page.toString(),
|
||||
perPage: perPage.toString(),
|
||||
sort: JSON.stringify(sorting),
|
||||
...pickBy(filters, Boolean),
|
||||
}),
|
||||
});
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<DataTableSkeleton
|
||||
columnCount={4}
|
||||
filterCount={3}
|
||||
cellWidths={["20rem", "10rem", "7rem", "4rem"]}
|
||||
shrinkZero
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<DataTableToolbar table={table} />
|
||||
<DataTable table={table} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import { BillingStatus, config } from "@turbostarter/billing";
|
||||
import { isKey, useTranslation } from "@turbostarter/i18n";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
|
||||
|
||||
import { CustomerActions } from "~/modules/admin/customers/data-table/columns";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { User } from "@turbostarter/auth";
|
||||
import type { Customer } from "@turbostarter/billing";
|
||||
|
||||
export const useColumns = (): ColumnDef<
|
||||
Customer & { user: Pick<User, "name"> }
|
||||
>[] => {
|
||||
const { t, i18n } = useTranslation(["common", "billing"]);
|
||||
|
||||
return [
|
||||
{
|
||||
id: "customerId",
|
||||
accessorKey: "customerId",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("id")} />
|
||||
),
|
||||
meta: {
|
||||
label: t("id"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
accessorKey: "plan",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("common:plan")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const plan = config.plans.find((plan) => plan.id === row.original.plan);
|
||||
|
||||
if (!plan) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="outline">
|
||||
{isKey(plan.name, i18n, "billing") ? t(plan.name) : plan.name}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("common:plan"),
|
||||
variant: "multiSelect",
|
||||
options: Object.values(config.plans).map((plan) => ({
|
||||
label: isKey(plan.name, i18n, "billing") ? t(plan.name) : plan.name,
|
||||
value: plan.id,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("common:status")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const statusKey = `status.${row.original.status?.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())}`;
|
||||
|
||||
if (!row.original.status) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
{isKey(statusKey, i18n, "billing") ? t(statusKey) : statusKey}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("common:status"),
|
||||
variant: "multiSelect",
|
||||
options: Object.values(BillingStatus).map((status) => {
|
||||
const statusKey = `status.${status.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())}`;
|
||||
return {
|
||||
label: isKey(statusKey, i18n, "billing") ? t(statusKey) : statusKey,
|
||||
value: status,
|
||||
};
|
||||
}),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<DataTableColumnHeader column={column} title={t("createdAt")} />
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="ml-auto text-right">
|
||||
{row.original.createdAt.toLocaleDateString(i18n.language)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("createdAt"),
|
||||
variant: "dateRange",
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<CustomerActions customer={row.original} />
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { pickBy } from "@turbostarter/shared/utils";
|
||||
import { DataTable } from "@turbostarter/ui-web/data-table/data-table";
|
||||
import { DataTableSkeleton } from "@turbostarter/ui-web/data-table/data-table-skeleton";
|
||||
import { DataTableToolbar } from "@turbostarter/ui-web/data-table/data-table-toolbar";
|
||||
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
import { useDataTable } from "~/modules/common/hooks/use-data-table";
|
||||
|
||||
import { useColumns } from "./columns";
|
||||
|
||||
interface PlansDataTableProps {
|
||||
readonly userId: string;
|
||||
}
|
||||
|
||||
export const PlansDataTable = ({ userId }: PlansDataTableProps) => {
|
||||
const columns = useColumns();
|
||||
|
||||
const { table, query } = useDataTable({
|
||||
persistance: "local",
|
||||
columns,
|
||||
initialState: {
|
||||
sorting: [
|
||||
{
|
||||
id: "plan",
|
||||
desc: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
enableRowSelection: false,
|
||||
query: ({ page, perPage, sorting, filters }) =>
|
||||
admin.queries.users.getPlans({
|
||||
id: userId,
|
||||
page: page.toString(),
|
||||
perPage: perPage.toString(),
|
||||
sort: JSON.stringify(sorting),
|
||||
...pickBy(filters, Boolean),
|
||||
}),
|
||||
});
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<DataTableSkeleton
|
||||
columnCount={4}
|
||||
filterCount={3}
|
||||
cellWidths={["20rem", "10rem", "7rem", "4rem"]}
|
||||
shrinkZero
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<DataTableToolbar table={table} />
|
||||
<DataTable table={table} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
126
apps/web/src/modules/admin/users/user/sessions/sessions-list.tsx
Normal file
126
apps/web/src/modules/admin/users/user/sessions/sessions-list.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
|
||||
import { admin } from "~/modules/admin/lib/api";
|
||||
|
||||
interface SessionsListProps {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export const SessionsList = ({ id }: SessionsListProps) => {
|
||||
const { t } = useTranslation(["common", "auth", "admin"]);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: sessions, isLoading } = useQuery(
|
||||
admin.queries.users.getSessions({ id }),
|
||||
);
|
||||
|
||||
const revoke = useMutation({
|
||||
...admin.mutations.users.sessions.revoke,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(
|
||||
admin.queries.users.getSessions({ id }),
|
||||
);
|
||||
toast.success(t("account.sessions.revoke.success"));
|
||||
},
|
||||
});
|
||||
|
||||
const revokeAll = useMutation({
|
||||
...admin.mutations.users.sessions.revokeAll,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(
|
||||
admin.queries.users.getSessions({ id }),
|
||||
);
|
||||
toast.success(t("users.user.sessions.revokeAll.success"));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="flex w-full flex-col gap-4">
|
||||
<header className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-xl font-semibold tracking-tight">
|
||||
{t("sessions")}
|
||||
</h3>
|
||||
|
||||
{sessions?.sessions.length && sessions.sessions.length > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => revokeAll.mutate({ userId: id })}
|
||||
disabled={revokeAll.isPending && revokeAll.variables.userId === id}
|
||||
>
|
||||
{revokeAll.isPending && revokeAll.variables.userId === id ? (
|
||||
<Icons.Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icons.Trash className="size-4" />
|
||||
)}
|
||||
{t("revokeAll")}
|
||||
</Button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="h-16" />
|
||||
<Skeleton className="h-16" />
|
||||
</div>
|
||||
) : sessions?.sessions.length && sessions.sessions.length > 0 ? (
|
||||
<ul className="overflow-hidden rounded-md border">
|
||||
{sessions.sessions.map((session) => {
|
||||
return (
|
||||
<li
|
||||
key={session.id}
|
||||
className="flex min-w-0 items-center gap-3 border-b px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<Icons.MonitorSmartphone className="size-6 shrink-0" />
|
||||
|
||||
<div className="mr-auto grid grid-cols-1">
|
||||
<span className="truncate text-sm font-medium capitalize">
|
||||
{session.ipAddress}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{session.userAgent}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={
|
||||
revoke.isPending &&
|
||||
revoke.variables.sessionToken === session.token
|
||||
}
|
||||
onClick={() => revoke.mutate({ sessionToken: session.token })}
|
||||
className="shrink-0"
|
||||
>
|
||||
<span className="sr-only">
|
||||
{t("account.sessions.revoke.cta")}
|
||||
</span>
|
||||
{revoke.isPending &&
|
||||
revoke.variables.sessionToken === session.token ? (
|
||||
<Icons.Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icons.Trash className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="flex items-center justify-center rounded-md border border-dashed p-14">
|
||||
<p className="text-center text-sm">
|
||||
{t("account.sessions.noSessions")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
537
apps/web/src/modules/agent/index.tsx
Normal file
537
apps/web/src/modules/agent/index.tsx
Normal file
@@ -0,0 +1,537 @@
|
||||
"use client";
|
||||
|
||||
import React, { forwardRef, useRef } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { AnimatedBeam } from "@turbostarter/ui-web/animated-beam";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
const Circle = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ className?: string; children?: React.ReactNode }
|
||||
>(({ className, children }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-card z-10 flex size-12 items-center justify-center rounded-full border-2 p-3 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Circle.displayName = "Circle";
|
||||
|
||||
const Beam = ({ className }: { className?: string }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const div1Ref = useRef<HTMLDivElement>(null);
|
||||
const div2Ref = useRef<HTMLDivElement>(null);
|
||||
const div3Ref = useRef<HTMLDivElement>(null);
|
||||
const div4Ref = useRef<HTMLDivElement>(null);
|
||||
const div5Ref = useRef<HTMLDivElement>(null);
|
||||
const div6Ref = useRef<HTMLDivElement>(null);
|
||||
const div7Ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex w-full items-center justify-center overflow-hidden p-4",
|
||||
className,
|
||||
)}
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className="flex size-full max-w-lg flex-row items-stretch justify-between gap-10">
|
||||
<div className="flex flex-col justify-center">
|
||||
<Circle ref={div7Ref}>
|
||||
<Icons.UserRound />
|
||||
</Circle>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center">
|
||||
<Circle ref={div6Ref} className="text-primary size-16">
|
||||
<Icons.Logo className="w-3/4" />
|
||||
</Circle>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center gap-2">
|
||||
<Circle ref={div1Ref}>
|
||||
<LocalIcons.googleDrive />
|
||||
</Circle>
|
||||
<Circle ref={div2Ref}>
|
||||
<LocalIcons.googleDocs />
|
||||
</Circle>
|
||||
<Circle ref={div3Ref}>
|
||||
<LocalIcons.whatsapp />
|
||||
</Circle>
|
||||
<Circle ref={div4Ref}>
|
||||
<LocalIcons.messenger />
|
||||
</Circle>
|
||||
<Circle ref={div5Ref}>
|
||||
<LocalIcons.notion />
|
||||
</Circle>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatedBeam
|
||||
containerRef={containerRef}
|
||||
fromRef={div1Ref}
|
||||
toRef={div6Ref}
|
||||
duration={3}
|
||||
/>
|
||||
<AnimatedBeam
|
||||
containerRef={containerRef}
|
||||
fromRef={div2Ref}
|
||||
toRef={div6Ref}
|
||||
duration={3}
|
||||
/>
|
||||
<AnimatedBeam
|
||||
containerRef={containerRef}
|
||||
fromRef={div3Ref}
|
||||
toRef={div6Ref}
|
||||
duration={3}
|
||||
/>
|
||||
<AnimatedBeam
|
||||
containerRef={containerRef}
|
||||
fromRef={div4Ref}
|
||||
toRef={div6Ref}
|
||||
duration={3}
|
||||
/>
|
||||
<AnimatedBeam
|
||||
containerRef={containerRef}
|
||||
fromRef={div5Ref}
|
||||
toRef={div6Ref}
|
||||
duration={3}
|
||||
/>
|
||||
<AnimatedBeam
|
||||
containerRef={containerRef}
|
||||
fromRef={div6Ref}
|
||||
toRef={div7Ref}
|
||||
duration={3}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LocalIcons = {
|
||||
notion: () => (
|
||||
<svg
|
||||
width="100"
|
||||
height="100"
|
||||
viewBox="0 0 100 100"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.017 4.313l55.333 -4.087c6.797 -0.583 8.543 -0.19 12.817 2.917l17.663 12.443c2.913 2.14 3.883 2.723 3.883 5.053v68.243c0 4.277 -1.553 6.807 -6.99 7.193L24.467 99.967c-4.08 0.193 -6.023 -0.39 -8.16 -3.113L3.3 79.94c-2.333 -3.113 -3.3 -5.443 -3.3 -8.167V11.113c0 -3.497 1.553 -6.413 6.017 -6.8z"
|
||||
fill="#ffffff"
|
||||
/>
|
||||
<path
|
||||
d="M61.35 0.227l-55.333 4.087C1.553 4.7 0 7.617 0 11.113v60.66c0 2.723 0.967 5.053 3.3 8.167l13.007 16.913c2.137 2.723 4.08 3.307 8.16 3.113l64.257 -3.89c5.433 -0.387 6.99 -2.917 6.99 -7.193V20.64c0 -2.21 -0.873 -2.847 -3.443 -4.733L74.167 3.143c-4.273 -3.107 -6.02 -3.5 -12.817 -2.917zM25.92 19.523c-5.247 0.353 -6.437 0.433 -9.417 -1.99L8.927 11.507c-0.77 -0.78 -0.383 -1.753 1.557 -1.947l53.193 -3.887c4.467 -0.39 6.793 1.167 8.54 2.527l9.123 6.61c0.39 0.197 1.36 1.36 0.193 1.36l-54.933 3.307 -0.68 0.047zM19.803 88.3V30.367c0 -2.53 0.777 -3.697 3.103 -3.893L86 22.78c2.14 -0.193 3.107 1.167 3.107 3.693v57.547c0 2.53 -0.39 4.67 -3.883 4.863l-60.377 3.5c-3.493 0.193 -5.043 -0.97 -5.043 -4.083zm59.6 -54.827c0.387 1.75 0 3.5 -1.75 3.7l-2.91 0.577v42.773c-2.527 1.36 -4.853 2.137 -6.797 2.137 -3.107 0 -3.883 -0.973 -6.21 -3.887l-19.03 -29.94v28.967l6.02 1.363s0 3.5 -4.857 3.5l-13.39 0.777c-0.39 -0.78 0 -2.723 1.357 -3.11l3.497 -0.97v-38.3L30.48 40.667c-0.39 -1.75 0.58 -4.277 3.3 -4.473l14.367 -0.967 19.8 30.327v-26.83l-5.047 -0.58c-0.39 -2.143 1.163 -3.7 3.103 -3.89l13.4 -0.78z"
|
||||
fill="#000000"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
openai: () => (
|
||||
<svg
|
||||
width="100"
|
||||
height="100"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
|
||||
</svg>
|
||||
),
|
||||
googleDrive: () => (
|
||||
<svg
|
||||
width="100"
|
||||
height="100"
|
||||
viewBox="0 0 87.3 78"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z"
|
||||
fill="#0066da"
|
||||
/>
|
||||
<path
|
||||
d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z"
|
||||
fill="#00ac47"
|
||||
/>
|
||||
<path
|
||||
d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z"
|
||||
fill="#ea4335"
|
||||
/>
|
||||
<path
|
||||
d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z"
|
||||
fill="#00832d"
|
||||
/>
|
||||
<path
|
||||
d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z"
|
||||
fill="#2684fc"
|
||||
/>
|
||||
<path
|
||||
d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z"
|
||||
fill="#ffba00"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
whatsapp: () => (
|
||||
<svg
|
||||
width="100"
|
||||
height="100"
|
||||
viewBox="0 0 175.216 175.552"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="b"
|
||||
x1="85.915"
|
||||
x2="86.535"
|
||||
y1="32.567"
|
||||
y2="137.092"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#57d163" />
|
||||
<stop offset="1" stopColor="#23b33a" />
|
||||
</linearGradient>
|
||||
<filter
|
||||
id="a"
|
||||
width="1.115"
|
||||
height="1.114"
|
||||
x="-.057"
|
||||
y="-.057"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="3.531" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="m54.532 138.45 2.235 1.324c9.387 5.571 20.15 8.518 31.126 8.523h.023c33.707 0 61.139-27.426 61.153-61.135.006-16.335-6.349-31.696-17.895-43.251A60.75 60.75 0 0 0 87.94 25.983c-33.733 0-61.166 27.423-61.178 61.13a60.98 60.98 0 0 0 9.349 32.535l1.455 2.312-6.179 22.558zm-40.811 23.544L24.16 123.88c-6.438-11.154-9.825-23.808-9.821-36.772.017-40.556 33.021-73.55 73.578-73.55 19.681.01 38.154 7.669 52.047 21.572s21.537 32.383 21.53 52.037c-.018 40.553-33.027 73.553-73.578 73.553h-.032c-12.313-.005-24.412-3.094-35.159-8.954zm0 0"
|
||||
fill="#b3b3b3"
|
||||
filter="url(#a)"
|
||||
/>
|
||||
<path
|
||||
d="m12.966 161.238 10.439-38.114a73.42 73.42 0 0 1-9.821-36.772c.017-40.556 33.021-73.55 73.578-73.55 19.681.01 38.154 7.669 52.047 21.572s21.537 32.383 21.53 52.037c-.018 40.553-33.027 73.553-73.578 73.553h-.032c-12.313-.005-24.412-3.094-35.159-8.954z"
|
||||
fill="#ffffff"
|
||||
/>
|
||||
<path
|
||||
d="M87.184 25.227c-33.733 0-61.166 27.423-61.178 61.13a60.98 60.98 0 0 0 9.349 32.535l1.455 2.312-6.179 22.559 23.146-6.069 2.235 1.324c9.387 5.571 20.15 8.518 31.126 8.524h.023c33.707 0 61.14-27.426 61.153-61.135a60.75 60.75 0 0 0-17.895-43.251 60.75 60.75 0 0 0-43.235-17.929z"
|
||||
fill="url(#linearGradient1780)"
|
||||
/>
|
||||
<path
|
||||
d="M87.184 25.227c-33.733 0-61.166 27.423-61.178 61.13a60.98 60.98 0 0 0 9.349 32.535l1.455 2.313-6.179 22.558 23.146-6.069 2.235 1.324c9.387 5.571 20.15 8.517 31.126 8.523h.023c33.707 0 61.14-27.426 61.153-61.135a60.75 60.75 0 0 0-17.895-43.251 60.75 60.75 0 0 0-43.235-17.928z"
|
||||
fill="url(#b)"
|
||||
/>
|
||||
<path
|
||||
d="M68.772 55.603c-1.378-3.061-2.828-3.123-4.137-3.176l-3.524-.043c-1.226 0-3.218.46-4.902 2.3s-6.435 6.287-6.435 15.332 6.588 17.785 7.506 19.013 12.718 20.381 31.405 27.75c15.529 6.124 18.689 4.906 22.061 4.6s10.877-4.447 12.408-8.74 1.532-7.971 1.073-8.74-1.685-1.226-3.525-2.146-10.877-5.367-12.562-5.981-2.91-.919-4.137.921-4.746 5.979-5.819 7.206-2.144 1.381-3.984.462-7.76-2.861-14.784-9.124c-5.465-4.873-9.154-10.891-10.228-12.73s-.114-2.835.808-3.751c.825-.824 1.838-2.147 2.759-3.22s1.224-1.84 1.836-3.065.307-2.301-.153-3.22-4.032-10.011-5.666-13.647"
|
||||
fill="#ffffff"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
googleDocs: () => (
|
||||
<svg
|
||||
width="47px"
|
||||
height="65px"
|
||||
viewBox="0 0 47 65"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<path
|
||||
d="M29.375,0 L4.40625,0 C1.9828125,0 0,1.99431818 0,4.43181818 L0,60.5681818 C0,63.0056818 1.9828125,65 4.40625,65 L42.59375,65 C45.0171875,65 47,63.0056818 47,60.5681818 L47,17.7272727 L29.375,0 Z"
|
||||
id="path-1"
|
||||
/>
|
||||
<path
|
||||
d="M29.375,0 L4.40625,0 C1.9828125,0 0,1.99431818 0,4.43181818 L0,60.5681818 C0,63.0056818 1.9828125,65 4.40625,65 L42.59375,65 C45.0171875,65 47,63.0056818 47,60.5681818 L47,17.7272727 L29.375,0 Z"
|
||||
id="path-3"
|
||||
/>
|
||||
<linearGradient
|
||||
x1="50.0053945%"
|
||||
y1="8.58610612%"
|
||||
x2="50.0053945%"
|
||||
y2="100.013939%"
|
||||
id="linearGradient-5"
|
||||
>
|
||||
<stop stopColor="#1A237E" stopOpacity="0.2" offset="0%" />
|
||||
<stop stopColor="#1A237E" stopOpacity="0.02" offset="100%" />
|
||||
</linearGradient>
|
||||
<path
|
||||
d="M29.375,0 L4.40625,0 C1.9828125,0 0,1.99431818 0,4.43181818 L0,60.5681818 C0,63.0056818 1.9828125,65 4.40625,65 L42.59375,65 C45.0171875,65 47,63.0056818 47,60.5681818 L47,17.7272727 L29.375,0 Z"
|
||||
id="path-6"
|
||||
/>
|
||||
<path
|
||||
d="M29.375,0 L4.40625,0 C1.9828125,0 0,1.99431818 0,4.43181818 L0,60.5681818 C0,63.0056818 1.9828125,65 4.40625,65 L42.59375,65 C45.0171875,65 47,63.0056818 47,60.5681818 L47,17.7272727 L29.375,0 Z"
|
||||
id="path-8"
|
||||
/>
|
||||
<path
|
||||
d="M29.375,0 L4.40625,0 C1.9828125,0 0,1.99431818 0,4.43181818 L0,60.5681818 C0,63.0056818 1.9828125,65 4.40625,65 L42.59375,65 C45.0171875,65 47,63.0056818 47,60.5681818 L47,17.7272727 L29.375,0 Z"
|
||||
id="path-10"
|
||||
/>
|
||||
<path
|
||||
d="M29.375,0 L4.40625,0 C1.9828125,0 0,1.99431818 0,4.43181818 L0,60.5681818 C0,63.0056818 1.9828125,65 4.40625,65 L42.59375,65 C45.0171875,65 47,63.0056818 47,60.5681818 L47,17.7272727 L29.375,0 Z"
|
||||
id="path-12"
|
||||
/>
|
||||
<path
|
||||
d="M29.375,0 L4.40625,0 C1.9828125,0 0,1.99431818 0,4.43181818 L0,60.5681818 C0,63.0056818 1.9828125,65 4.40625,65 L42.59375,65 C45.0171875,65 47,63.0056818 47,60.5681818 L47,17.7272727 L29.375,0 Z"
|
||||
id="path-14"
|
||||
/>
|
||||
<radialGradient
|
||||
cx="3.16804688%"
|
||||
cy="2.71744318%"
|
||||
fx="3.16804688%"
|
||||
fy="2.71744318%"
|
||||
r="161.248516%"
|
||||
gradientTransform="translate(0.031680,0.027174),scale(1.000000,0.723077),translate(-0.031680,-0.027174)"
|
||||
id="radialGradient-16"
|
||||
>
|
||||
<stop stopColor="#FFFFFF" stopOpacity="0.1" offset="0%" />
|
||||
<stop stopColor="#FFFFFF" stopOpacity="0" offset="100%" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<g
|
||||
id="Page-1"
|
||||
stroke="none"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
fillRule="evenodd"
|
||||
>
|
||||
<g transform="translate(-451.000000, -463.000000)">
|
||||
<g id="Hero" transform="translate(0.000000, 63.000000)">
|
||||
<g id="Personal" transform="translate(277.000000, 309.000000)">
|
||||
<g id="Docs-icon" transform="translate(174.000000, 91.000000)">
|
||||
<g id="Group">
|
||||
<g id="Clipped">
|
||||
<mask id="mask-2" fill="white">
|
||||
<use xlinkHref="#path-1" />
|
||||
</mask>
|
||||
<g id="SVGID_1_" />
|
||||
<path
|
||||
d="M29.375,0 L4.40625,0 C1.9828125,0 0,1.99431818 0,4.43181818 L0,60.5681818 C0,63.0056818 1.9828125,65 4.40625,65 L42.59375,65 C45.0171875,65 47,63.0056818 47,60.5681818 L47,17.7272727 L36.71875,10.3409091 L29.375,0 Z"
|
||||
id="Path"
|
||||
fill="#4285F4"
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-2)"
|
||||
/>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-4" fill="white">
|
||||
<use xlinkHref="#path-3" />
|
||||
</mask>
|
||||
<g id="SVGID_1_" />
|
||||
<polygon
|
||||
id="Path"
|
||||
fill="url(#linearGradient-5)"
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-4)"
|
||||
points="30.6638281 16.4309659 47 32.8582386 47 17.7272727"
|
||||
></polygon>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-7" fill="white">
|
||||
<use xlinkHref="#path-6" />
|
||||
</mask>
|
||||
<g id="SVGID_1_" />
|
||||
<path
|
||||
d="M11.75,47.2727273 L35.25,47.2727273 L35.25,44.3181818 L11.75,44.3181818 L11.75,47.2727273 Z M11.75,53.1818182 L29.375,53.1818182 L29.375,50.2272727 L11.75,50.2272727 L11.75,53.1818182 Z M11.75,32.5 L11.75,35.4545455 L35.25,35.4545455 L35.25,32.5 L11.75,32.5 Z M11.75,41.3636364 L35.25,41.3636364 L35.25,38.4090909 L11.75,38.4090909 L11.75,41.3636364 Z"
|
||||
id="Shape"
|
||||
fill="#F1F1F1"
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-7)"
|
||||
/>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-9" fill="white">
|
||||
<use xlinkHref="#path-8" />
|
||||
</mask>
|
||||
<g id="SVGID_1_" />
|
||||
<g id="Group" mask="url(#mask-9)">
|
||||
<g transform="translate(26.437500, -2.954545)">
|
||||
<path
|
||||
d="M2.9375,2.95454545 L2.9375,16.25 C2.9375,18.6985795 4.90929688,20.6818182 7.34375,20.6818182 L20.5625,20.6818182 L2.9375,2.95454545 Z"
|
||||
id="Path"
|
||||
fill="#A1C2FA"
|
||||
fillRule="nonzero"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-11" fill="white">
|
||||
<use xlinkHref="#path-10" />
|
||||
</mask>
|
||||
<g id="SVGID_1_" />
|
||||
<path
|
||||
d="M4.40625,0 C1.9828125,0 0,1.99431818 0,4.43181818 L0,4.80113636 C0,2.36363636 1.9828125,0.369318182 4.40625,0.369318182 L29.375,0.369318182 L29.375,0 L4.40625,0 Z"
|
||||
id="Path"
|
||||
fillOpacity="0.2"
|
||||
fill="#FFFFFF"
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-11)"
|
||||
/>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-13" fill="white">
|
||||
<use xlinkHref="#path-12" />
|
||||
</mask>
|
||||
<g id="SVGID_1_" />
|
||||
<path
|
||||
d="M42.59375,64.6306818 L4.40625,64.6306818 C1.9828125,64.6306818 0,62.6363636 0,60.1988636 L0,60.5681818 C0,63.0056818 1.9828125,65 4.40625,65 L42.59375,65 C45.0171875,65 47,63.0056818 47,60.5681818 L47,60.1988636 C47,62.6363636 45.0171875,64.6306818 42.59375,64.6306818 Z"
|
||||
id="Path"
|
||||
fillOpacity="0.2"
|
||||
fill="#1A237E"
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-13)"
|
||||
/>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-15" fill="white">
|
||||
<use xlinkHref="#path-14" />
|
||||
</mask>
|
||||
<g id="SVGID_1_" />
|
||||
<path
|
||||
d="M33.78125,17.7272727 C31.3467969,17.7272727 29.375,15.7440341 29.375,13.2954545 L29.375,13.6647727 C29.375,16.1133523 31.3467969,18.0965909 33.78125,18.0965909 L47,18.0965909 L47,17.7272727 L33.78125,17.7272727 Z"
|
||||
id="Path"
|
||||
fillOpacity="0.1"
|
||||
fill="#1A237E"
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-15)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<path
|
||||
d="M29.375,0 L4.40625,0 C1.9828125,0 0,1.99431818 0,4.43181818 L0,60.5681818 C0,63.0056818 1.9828125,65 4.40625,65 L42.59375,65 C45.0171875,65 47,63.0056818 47,60.5681818 L47,17.7272727 L29.375,0 Z"
|
||||
id="Path"
|
||||
fill="url(#radialGradient-16)"
|
||||
fillRule="nonzero"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
),
|
||||
zapier: () => (
|
||||
<svg
|
||||
width="105"
|
||||
height="28"
|
||||
viewBox="0 0 244 66"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M57.1877 45.2253L57.1534 45.1166L78.809 25.2914V15.7391H44.0663V25.2914H64.8181L64.8524 25.3829L43.4084 45.2253V54.7775H79.1579V45.2253H57.1877Z"
|
||||
fill="#201515"
|
||||
/>
|
||||
<path
|
||||
d="M100.487 14.8297C96.4797 14.8297 93.2136 15.434 90.6892 16.6429C88.3376 17.6963 86.3568 19.4321 85.0036 21.6249C83.7091 23.8321 82.8962 26.2883 82.6184 28.832L93.1602 30.3135C93.5415 28.0674 94.3042 26.4754 95.4482 25.5373C96.7486 24.5562 98.3511 24.0605 99.9783 24.136C102.118 24.136 103.67 24.7079 104.634 25.8519C105.59 26.9959 106.076 28.5803 106.076 30.6681V31.7091H95.9401C90.7807 31.7091 87.0742 32.8531 84.8206 35.1411C82.5669 37.429 81.442 40.4492 81.4458 44.2014C81.4458 48.0452 82.5707 50.9052 84.8206 52.7813C87.0704 54.6574 89.8999 55.5897 93.3089 55.5783C97.5379 55.5783 100.791 54.1235 103.067 51.214C104.412 49.426 105.372 47.3793 105.887 45.2024H106.27L107.723 54.7546H117.275V30.5651C117.275 25.5659 115.958 21.6936 113.323 18.948C110.688 16.2024 106.409 14.8297 100.487 14.8297ZM103.828 44.6475C102.312 45.9116 100.327 46.5408 97.8562 46.5408C95.8199 46.5408 94.4052 46.1843 93.6121 45.4712C93.2256 45.1338 92.9182 44.7155 92.7116 44.246C92.505 43.7764 92.4043 43.2671 92.4166 42.7543C92.3941 42.2706 92.4702 41.7874 92.6403 41.3341C92.8104 40.8808 93.071 40.4668 93.4062 40.1174C93.7687 39.7774 94.1964 39.5145 94.6633 39.3444C95.1303 39.1743 95.6269 39.1006 96.1231 39.1278H106.093V39.7856C106.113 40.7154 105.919 41.6374 105.527 42.4804C105.134 43.3234 104.553 44.0649 103.828 44.6475Z"
|
||||
fill="#201515"
|
||||
/>
|
||||
<path
|
||||
d="M175.035 15.7391H163.75V54.7833H175.035V15.7391Z"
|
||||
fill="#201515"
|
||||
/>
|
||||
<path
|
||||
d="M241.666 15.7391C238.478 15.7391 235.965 16.864 234.127 19.1139C232.808 20.7307 231.805 23.1197 231.119 26.2809H230.787L229.311 15.7391H219.673V54.7775H230.959V34.7578C230.959 32.2335 231.55 30.2982 232.732 28.9521C233.914 27.606 236.095 26.933 239.275 26.933H243.559V15.7391H241.666Z"
|
||||
fill="#201515"
|
||||
/>
|
||||
<path
|
||||
d="M208.473 17.0147C205.839 15.4474 202.515 14.6657 198.504 14.6695C192.189 14.6695 187.247 16.4675 183.678 20.0634C180.108 23.6593 178.324 28.6166 178.324 34.9352C178.233 38.7553 179.067 42.5407 180.755 45.9689C182.3 49.0238 184.706 51.5592 187.676 53.2618C190.665 54.9892 194.221 55.8548 198.344 55.8586C201.909 55.8586 204.887 55.3095 207.278 54.2113C209.526 53.225 211.483 51.6791 212.964 49.7211C214.373 47.7991 215.42 45.6359 216.052 43.3377L206.329 40.615C205.919 42.1094 205.131 43.4728 204.041 44.5732C202.942 45.6714 201.102 46.2206 198.521 46.2206C195.451 46.2206 193.163 45.3416 191.657 43.5837C190.564 42.3139 189.878 40.5006 189.575 38.1498H216.201C216.31 37.0515 216.367 36.1306 216.367 35.387V32.9561C216.431 29.6903 215.757 26.4522 214.394 23.4839C213.118 20.7799 211.054 18.5248 208.473 17.0147ZM198.178 23.9758C202.754 23.9758 205.348 26.2275 205.962 30.731H189.775C190.032 29.2284 190.655 27.8121 191.588 26.607C193.072 24.8491 195.268 23.972 198.178 23.9758Z"
|
||||
fill="#201515"
|
||||
/>
|
||||
<path
|
||||
d="M169.515 0.00366253C168.666 -0.0252113 167.82 0.116874 167.027 0.421484C166.234 0.726094 165.511 1.187 164.899 1.77682C164.297 2.3723 163.824 3.08658 163.512 3.87431C163.2 4.66204 163.055 5.50601 163.086 6.35275C163.056 7.20497 163.201 8.05433 163.514 8.84781C163.826 9.64129 164.299 10.3619 164.902 10.9646C165.505 11.5673 166.226 12.0392 167.02 12.3509C167.814 12.6626 168.663 12.8074 169.515 12.7762C170.362 12.8082 171.206 12.6635 171.994 12.3514C172.782 12.0392 173.496 11.5664 174.091 10.963C174.682 10.3534 175.142 9.63077 175.446 8.83849C175.75 8.04621 175.89 7.20067 175.859 6.35275C175.898 5.50985 175.761 4.66806 175.456 3.88115C175.151 3.09424 174.686 2.37951 174.09 1.78258C173.493 1.18565 172.779 0.719644 171.992 0.414327C171.206 0.109011 170.364 -0.0288946 169.521 0.00938803L169.515 0.00366253Z"
|
||||
fill="#201515"
|
||||
/>
|
||||
<path
|
||||
d="M146.201 14.6695C142.357 14.6695 139.268 15.8764 136.935 18.2902C135.207 20.0786 133.939 22.7479 133.131 26.2981H132.771L131.295 15.7563H121.657V66H132.942V45.3054H133.354C133.698 46.6852 134.181 48.0267 134.795 49.3093C135.75 51.3986 137.316 53.1496 139.286 54.3314C141.328 55.446 143.629 56.0005 145.955 55.9387C150.68 55.9387 154.277 54.0988 156.748 50.419C159.219 46.7392 160.455 41.6046 160.455 35.0153C160.455 28.6509 159.259 23.6689 156.869 20.0691C154.478 16.4694 150.922 14.6695 146.201 14.6695ZM147.345 42.9602C146.029 44.8668 143.97 45.8201 141.167 45.8201C140.012 45.8735 138.86 45.6507 137.808 45.1703C136.755 44.6898 135.832 43.9656 135.116 43.0574C133.655 41.2233 132.927 38.7122 132.931 35.5243V34.7807C132.931 31.5432 133.659 29.0646 135.116 27.3448C136.572 25.625 138.59 24.7747 141.167 24.7937C144.02 24.7937 146.092 25.6994 147.385 27.5107C148.678 29.322 149.324 31.8483 149.324 35.0896C149.332 38.4414 148.676 41.065 147.356 42.9602H147.345Z"
|
||||
fill="#201515"
|
||||
/>
|
||||
<path d="M39.0441 45.2253H0V54.789H39.0441V45.2253Z" fill="#FF4F00" />
|
||||
</svg>
|
||||
),
|
||||
messenger: () => (
|
||||
<svg
|
||||
width="100"
|
||||
height="100"
|
||||
viewBox="0 0 48 48"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<radialGradient
|
||||
id="8O3wK6b5ASW2Wn6hRCB5xa_YFbzdUk7Q3F8_gr1"
|
||||
cx="11.087"
|
||||
cy="7.022"
|
||||
r="47.612"
|
||||
gradientTransform="matrix(1 0 0 -1 0 50)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0" stopColor="#1292ff"></stop>
|
||||
<stop offset=".079" stopColor="#2982ff"></stop>
|
||||
<stop offset=".23" stopColor="#4e69ff"></stop>
|
||||
<stop offset=".351" stopColor="#6559ff"></stop>
|
||||
<stop offset=".428" stopColor="#6d53ff"></stop>
|
||||
<stop offset=".754" stopColor="#df47aa"></stop>
|
||||
<stop offset=".946" stopColor="#ff6257"></stop>
|
||||
</radialGradient>
|
||||
<path
|
||||
fill="url(#8O3wK6b5ASW2Wn6hRCB5xa_YFbzdUk7Q3F8_gr1)"
|
||||
d="M44,23.5C44,34.27,35.05,43,24,43c-1.651,0-3.25-0.194-4.784-0.564 c-0.465-0.112-0.951-0.069-1.379,0.145L13.46,44.77C12.33,45.335,11,44.513,11,43.249v-4.025c0-0.575-0.257-1.111-0.681-1.499 C6.425,34.165,4,29.11,4,23.5C4,12.73,12.95,4,24,4S44,12.73,44,23.5z"
|
||||
/>
|
||||
<path
|
||||
d="M34.992,17.292c-0.428,0-0.843,0.142-1.2,0.411l-5.694,4.215 c-0.133,0.1-0.28,0.15-0.435,0.15c-0.15,0-0.291-0.047-0.41-0.136l-3.972-2.99c-0.808-0.601-1.76-0.918-2.757-0.918 c-1.576,0-3.025,0.791-3.876,2.116l-1.211,1.891l-4.12,6.695c-0.392,0.614-0.422,1.372-0.071,2.014 c0.358,0.654,1.034,1.06,1.764,1.06c0.428,0,0.843-0.142,1.2-0.411l5.694-4.215c0.133-0.1,0.28-0.15,0.435-0.15 c0.15,0,0.291,0.047,0.41,0.136l3.972,2.99c0.809,0.602,1.76,0.918,2.757,0.918c1.576,0,3.025-0.791,3.876-2.116l1.211-1.891 l4.12-6.695c0.392-0.614,0.422-1.372,0.071-2.014C36.398,17.698,35.722,17.292,34.992,17.292L34.992,17.292z"
|
||||
opacity=".05"
|
||||
/>
|
||||
<path
|
||||
d="M34.992,17.792c-0.319,0-0.63,0.107-0.899,0.31l-5.697,4.218 c-0.216,0.163-0.468,0.248-0.732,0.248c-0.259,0-0.504-0.082-0.71-0.236l-3.973-2.991c-0.719-0.535-1.568-0.817-2.457-0.817 c-1.405,0-2.696,0.705-3.455,1.887l-1.21,1.891l-4.115,6.688c-0.297,0.465-0.32,1.033-0.058,1.511c0.266,0.486,0.787,0.8,1.325,0.8 c0.319,0,0.63-0.107,0.899-0.31l5.697-4.218c0.216-0.163,0.468-0.248,0.732-0.248c0.259,0,0.504,0.082,0.71,0.236l3.973,2.991 c0.719,0.535,1.568,0.817,2.457,0.817c1.405,0,2.696-0.705,3.455-1.887l1.21-1.891l4.115-6.688c0.297-0.465,0.32-1.033,0.058-1.511 C36.051,18.106,35.531,17.792,34.992,17.792L34.992,17.792z"
|
||||
opacity=".07"
|
||||
/>
|
||||
<path
|
||||
fill="#ffffff"
|
||||
d="M34.394,18.501l-5.7,4.22c-0.61,0.46-1.44,0.46-2.04,0.01L22.68,19.74 c-1.68-1.25-4.06-0.82-5.19,0.94l-1.21,1.89l-4.11,6.68c-0.6,0.94,0.55,2.01,1.44,1.34l5.7-4.22c0.61-0.46,1.44-0.46,2.04-0.01 l3.974,2.991c1.68,1.25,4.06,0.82,5.19-0.94l1.21-1.89l4.11-6.68C36.434,18.901,35.284,17.831,34.394,18.501z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
user: () => (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#000000"
|
||||
strokeWidth="2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export const Agent = () => {
|
||||
const { t } = useTranslation(["common", "ai"]);
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex w-full flex-col items-center justify-center gap-4">
|
||||
<Beam />
|
||||
<h1 className="px-6 text-center text-3xl leading-tight font-medium tracking-tight @lg:text-4xl">
|
||||
{t("agent.headline.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground max-w-md px-6 text-center">
|
||||
{t("agent.headline.subtitle")}
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="https://github.com/orgs/turbostarter/projects/1"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-4 hover:no-underline"
|
||||
>
|
||||
{t("seeRoadmap")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
62
apps/web/src/modules/auth/form/anonymous.tsx
Normal file
62
apps/web/src/modules/auth/form/anonymous.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
|
||||
import { auth } from "../lib/api";
|
||||
|
||||
import { useAuthFormStore } from "./store";
|
||||
|
||||
interface AnonymousLoginProps {
|
||||
readonly redirectTo?: string;
|
||||
}
|
||||
|
||||
export const AnonymousLogin = ({
|
||||
redirectTo = pathsConfig.dashboard.user.index,
|
||||
}: AnonymousLoginProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation("auth");
|
||||
const { provider, setProvider, isSubmitting, setIsSubmitting } =
|
||||
useAuthFormStore();
|
||||
|
||||
const signIn = useMutation({
|
||||
...auth.mutations.signIn.anonymous,
|
||||
onMutate: () => {
|
||||
setProvider(AuthProvider.ANONYMOUS);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
onSuccess: () => {
|
||||
router.replace(redirectTo);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
size="lg"
|
||||
type="button"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => signIn.mutate(undefined)}
|
||||
>
|
||||
{isSubmitting && provider === AuthProvider.ANONYMOUS ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Icons.UserRound className="size-4" />
|
||||
{t("login.anonymous.cta")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
5
apps/web/src/modules/auth/form/login/constants.ts
Normal file
5
apps/web/src/modules/auth/form/login/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
|
||||
export const LOGIN_OPTIONS = [AuthProvider.PASSWORD, AuthProvider.MAGIC_LINK];
|
||||
|
||||
export type LoginOption = (typeof LOGIN_OPTIONS)[number];
|
||||
133
apps/web/src/modules/auth/form/login/form.tsx
Normal file
133
apps/web/src/modules/auth/form/login/form.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
} from "@turbostarter/ui-web/tabs";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
import { MagicLinkLoginForm } from "./magic-link";
|
||||
import { PasswordLoginForm } from "./password";
|
||||
|
||||
import type { LoginOption } from "./constants";
|
||||
|
||||
const LOGIN_OPTIONS_DETAILS = {
|
||||
[AuthProvider.PASSWORD]: {
|
||||
lastUsedMethodId: "email",
|
||||
component: PasswordLoginForm,
|
||||
label: "password",
|
||||
},
|
||||
[AuthProvider.MAGIC_LINK]: {
|
||||
lastUsedMethodId: AuthProvider.MAGIC_LINK,
|
||||
component: MagicLinkLoginForm,
|
||||
label: "login.magicLink.label",
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface LoginFormProps {
|
||||
readonly options: LoginOption[];
|
||||
readonly redirectTo?: string;
|
||||
readonly email?: string;
|
||||
readonly onTwoFactorRedirect?: () => void;
|
||||
}
|
||||
|
||||
export const LoginForm = ({
|
||||
options,
|
||||
redirectTo,
|
||||
email,
|
||||
onTwoFactorRedirect,
|
||||
}: LoginFormProps) => {
|
||||
const { t } = useTranslation(["auth", "common"]);
|
||||
const [mainOption] = options;
|
||||
|
||||
if (!options.length || !mainOption) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (options.length === 1) {
|
||||
const Component = LOGIN_OPTIONS_DETAILS[mainOption].component;
|
||||
return (
|
||||
<Component
|
||||
redirectTo={redirectTo}
|
||||
email={email}
|
||||
onTwoFactorRedirect={onTwoFactorRedirect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue={mainOption}
|
||||
className="flex w-full flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
{options.map((provider) => (
|
||||
<TabsTrigger
|
||||
key={provider}
|
||||
value={provider}
|
||||
className="relative w-full"
|
||||
>
|
||||
{t(LOGIN_OPTIONS_DETAILS[provider].label)}
|
||||
|
||||
{authClient.isLastUsedLoginMethod(
|
||||
LOGIN_OPTIONS_DETAILS[provider].lastUsedMethodId,
|
||||
) && (
|
||||
<Badge className="absolute top-0 -right-4 z-10 -translate-y-1/2 shadow-sm">
|
||||
{t("lastUsed")}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{options.map((provider) => {
|
||||
const Component = LOGIN_OPTIONS_DETAILS[provider].component;
|
||||
return (
|
||||
<TabsContent key={provider} value={provider} className="mt-4 w-full">
|
||||
<Suspense>
|
||||
<Component
|
||||
redirectTo={redirectTo}
|
||||
email={email}
|
||||
onTwoFactorRedirect={onTwoFactorRedirect}
|
||||
/>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export const LoginCta = () => {
|
||||
const { t } = useTranslation("auth");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center pt-2">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t("register.alreadyHaveAccount")}
|
||||
<TurboLink
|
||||
href={
|
||||
searchParams.size > 0
|
||||
? `${pathsConfig.auth.login}?${searchParams.toString()}`
|
||||
: pathsConfig.auth.login
|
||||
}
|
||||
className="hover:text-primary pl-2 font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("login.cta")}!
|
||||
</TurboLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
138
apps/web/src/modules/auth/form/login/magic-link.tsx
Normal file
138
apps/web/src/modules/auth/form/login/magic-link.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import {
|
||||
AuthProvider,
|
||||
generateName,
|
||||
magicLinkLoginSchema,
|
||||
} from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Input } from "@turbostarter/ui-web/input";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { useAuthFormStore } from "~/modules/auth/form/store";
|
||||
import { onPromise } from "~/utils";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
interface MagicLinkLoginFormProps {
|
||||
readonly redirectTo?: string;
|
||||
readonly email?: string;
|
||||
}
|
||||
|
||||
export const MagicLinkLoginForm = memo<MagicLinkLoginFormProps>(
|
||||
({ redirectTo = pathsConfig.dashboard.user.index, email }) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const { provider, setProvider, isSubmitting, setIsSubmitting } =
|
||||
useAuthFormStore();
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(magicLinkLoginSchema),
|
||||
defaultValues: {
|
||||
email: email ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const signIn = useMutation({
|
||||
...auth.mutations.signIn.magicLink,
|
||||
onMutate: () => {
|
||||
setProvider(AuthProvider.MAGIC_LINK);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{form.formState.isSubmitSuccessful ? (
|
||||
<motion.div
|
||||
className="my-6 flex flex-col items-center justify-center gap-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
key="success"
|
||||
>
|
||||
<Icons.CheckCircle2
|
||||
className="text-success h-20 w-20"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
<h2 className="text-center text-2xl font-semibold tracking-tight">
|
||||
{t("login.magicLink.success.title")}
|
||||
</h2>
|
||||
<p className="text-center">
|
||||
{t("login.magicLink.success.description")}
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={onPromise(
|
||||
form.handleSubmit((data) =>
|
||||
signIn.mutateAsync({
|
||||
email: data.email,
|
||||
name: generateName(data.email),
|
||||
callbackURL: redirectTo,
|
||||
errorCallbackURL: pathsConfig.auth.error,
|
||||
}),
|
||||
),
|
||||
)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
disabled={form.formState.isSubmitting}
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && provider === AuthProvider.MAGIC_LINK ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("login.magicLink.cta")
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MagicLinkLoginForm.displayName = "MagicLinkLoginForm";
|
||||
73
apps/web/src/modules/auth/form/login/passkey.tsx
Normal file
73
apps/web/src/modules/auth/form/login/passkey.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
import { useAuthFormStore } from "../store";
|
||||
|
||||
interface PasskeyLoginProps {
|
||||
readonly redirectTo?: string;
|
||||
}
|
||||
|
||||
export const PasskeyLogin = ({
|
||||
redirectTo = pathsConfig.dashboard.user.index,
|
||||
}: PasskeyLoginProps) => {
|
||||
const router = useRouter();
|
||||
const { setProvider, setIsSubmitting, isSubmitting, provider } =
|
||||
useAuthFormStore();
|
||||
const { t } = useTranslation(["auth", "common"]);
|
||||
|
||||
const signIn = useMutation({
|
||||
...auth.mutations.signIn.passkey,
|
||||
onMutate: () => {
|
||||
setProvider(AuthProvider.PASSKEY);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
onSuccess: () => {
|
||||
router.replace(redirectTo);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void auth.mutations.signIn.passkey.mutationFn({ autoFill: true });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="relative gap-2"
|
||||
size="lg"
|
||||
onClick={() => signIn.mutate(undefined)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && provider === AuthProvider.PASSKEY ? (
|
||||
<Icons.Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Icons.Key className="size-4" />
|
||||
{t("login.passkey.cta")}
|
||||
</>
|
||||
)}
|
||||
|
||||
{authClient.isLastUsedLoginMethod(AuthProvider.PASSKEY) && (
|
||||
<Badge className="absolute top-0 -right-4 z-10 -translate-y-1/2 shadow-sm">
|
||||
{t("lastUsed")}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
169
apps/web/src/modules/auth/form/login/password.tsx
Normal file
169
apps/web/src/modules/auth/form/login/password.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { AuthProvider, passwordLoginSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Checkbox } from "@turbostarter/ui-web/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Input, PasswordInput } from "@turbostarter/ui-web/input";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { useAuthFormStore } from "~/modules/auth/form/store";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
import { onPromise } from "~/utils";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
interface PasswordLoginFormProps {
|
||||
readonly redirectTo?: string;
|
||||
readonly email?: string;
|
||||
readonly onTwoFactorRedirect?: () => void;
|
||||
}
|
||||
|
||||
export const PasswordLoginForm = memo<PasswordLoginFormProps>(
|
||||
({
|
||||
redirectTo = pathsConfig.dashboard.user.index,
|
||||
email,
|
||||
onTwoFactorRedirect,
|
||||
}) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const { provider, setProvider, isSubmitting, setIsSubmitting } =
|
||||
useAuthFormStore();
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(passwordLoginSchema),
|
||||
defaultValues: {
|
||||
rememberMe: true,
|
||||
email: email ?? "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const signIn = useMutation({
|
||||
...auth.mutations.signIn.email,
|
||||
onMutate: () => {
|
||||
setProvider(AuthProvider.PASSWORD);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
onSuccess: (ctx) => {
|
||||
if ("twoFactorRedirect" in ctx) {
|
||||
return onTwoFactorRedirect?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={onPromise(
|
||||
form.handleSubmit((data) =>
|
||||
signIn.mutateAsync({
|
||||
...data,
|
||||
callbackURL: redirectTo,
|
||||
}),
|
||||
),
|
||||
)}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common:email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
disabled={isSubmitting}
|
||||
autoComplete="email webauthn"
|
||||
inputMode="email"
|
||||
spellCheck={false}
|
||||
maxLength={254}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex flex-col-reverse gap-2">
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
disabled={isSubmitting}
|
||||
autoComplete="current-password webauthn"
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<FormLabel>{t("password")}</FormLabel>
|
||||
<TurboLink
|
||||
href={pathsConfig.auth.forgotPassword}
|
||||
className="text-muted-foreground hover:text-primary text-sm underline underline-offset-4"
|
||||
>
|
||||
{t("account.password.forgot.label")}
|
||||
</TurboLink>
|
||||
</div>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rememberMe"
|
||||
render={({ field }) => (
|
||||
<FormItem className="-mt-2 ml-px flex items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel>{t("rememberMe")}</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && provider === AuthProvider.PASSWORD ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("login.cta")
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PasswordLoginForm.displayName = "PasswordLoginForm";
|
||||
131
apps/web/src/modules/auth/form/password/forgot.tsx
Normal file
131
apps/web/src/modules/auth/form/password/forgot.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { forgotPasswordSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Input } from "@turbostarter/ui-web/input";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
import { onPromise } from "~/utils";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
export const ForgotPasswordForm = () => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(forgotPasswordSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
|
||||
const forgetPassword = useMutation({
|
||||
...auth.mutations.password.forget,
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{form.formState.isSubmitSuccessful ? (
|
||||
<motion.div
|
||||
className="mt-6 flex flex-col items-center justify-center gap-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
key="success"
|
||||
>
|
||||
<Icons.CheckCircle2
|
||||
className="text-success h-20 w-20"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
<h2 className="text-center text-2xl font-semibold tracking-tight">
|
||||
{t("account.password.forgot.success.title")}
|
||||
</h2>
|
||||
<p className="text-center">
|
||||
{t("account.password.forgot.success.description")}
|
||||
</p>
|
||||
<TurboLink
|
||||
href={pathsConfig.auth.login}
|
||||
className="text-muted-foreground hover:text-primary -mt-1 text-sm font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("login.cta")}
|
||||
</TurboLink>
|
||||
</motion.div>
|
||||
) : (
|
||||
<Form {...form} key="idle">
|
||||
<motion.form
|
||||
onSubmit={onPromise(
|
||||
form.handleSubmit((data) =>
|
||||
forgetPassword.mutateAsync({
|
||||
...data,
|
||||
redirectTo: pathsConfig.auth.updatePassword,
|
||||
}),
|
||||
),
|
||||
)}
|
||||
className="space-y-6"
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
disabled={form.formState.isSubmitting}
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("account.password.forgot.cta")
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-center pt-2">
|
||||
<TurboLink
|
||||
href={pathsConfig.auth.login}
|
||||
className="text-muted-foreground hover:text-primary pl-2 text-sm font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("account.password.forgot.back")}
|
||||
</TurboLink>
|
||||
</div>
|
||||
</motion.form>
|
||||
</Form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
99
apps/web/src/modules/auth/form/password/update.tsx
Normal file
99
apps/web/src/modules/auth/form/password/update.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { motion } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { updatePasswordSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { PasswordInput } from "@turbostarter/ui-web/input";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { onPromise } from "~/utils";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
interface UpdatePasswordFormProps {
|
||||
readonly token?: string;
|
||||
}
|
||||
|
||||
export const UpdatePasswordForm = memo<UpdatePasswordFormProps>(({ token }) => {
|
||||
const { t } = useTranslation("auth");
|
||||
const router = useRouter();
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(updatePasswordSchema),
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const resetPassword = useMutation({
|
||||
...auth.mutations.password.reset,
|
||||
onSuccess: () => {
|
||||
router.replace(pathsConfig.auth.login);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form} key="idle">
|
||||
<motion.form
|
||||
onSubmit={onPromise(
|
||||
form.handleSubmit((data) =>
|
||||
resetPassword.mutateAsync({
|
||||
newPassword: data.password,
|
||||
token,
|
||||
}),
|
||||
),
|
||||
)}
|
||||
className="space-y-6"
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("password")}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
disabled={form.formState.isSubmitting}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("account.password.update.cta")
|
||||
)}
|
||||
</Button>
|
||||
</motion.form>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
UpdatePasswordForm.displayName = "UpdatePasswordForm";
|
||||
177
apps/web/src/modules/auth/form/register-form.tsx
Normal file
177
apps/web/src/modules/auth/form/register-form.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { AuthProvider, registerSchema, generateName } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Input, PasswordInput } from "@turbostarter/ui-web/input";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
import { onPromise } from "~/utils";
|
||||
|
||||
import { auth } from "../lib/api";
|
||||
|
||||
import { useAuthFormStore } from "./store";
|
||||
|
||||
interface RegisterFormProps {
|
||||
readonly redirectTo?: string;
|
||||
readonly email?: string;
|
||||
}
|
||||
|
||||
export const RegisterForm = memo<RegisterFormProps>(
|
||||
({ redirectTo = pathsConfig.dashboard.user.index, email }) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const { provider, setProvider, isSubmitting, setIsSubmitting } =
|
||||
useAuthFormStore();
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(registerSchema),
|
||||
defaultValues: {
|
||||
email: email ?? "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const signUp = useMutation({
|
||||
...auth.mutations.signUp.email,
|
||||
onMutate: () => {
|
||||
setProvider(AuthProvider.PASSWORD);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{form.formState.isSubmitSuccessful ? (
|
||||
<motion.div
|
||||
className="my-6 flex flex-col items-center justify-center gap-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
key="success"
|
||||
>
|
||||
<Icons.CheckCircle2
|
||||
className="text-success h-20 w-20"
|
||||
strokeWidth={1.2}
|
||||
/>
|
||||
<h2 className="text-center text-2xl font-semibold tracking-tight">
|
||||
{t("register.success.title")}
|
||||
</h2>
|
||||
<p className="text-center">{t("register.success.description")}</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<Form {...form} key="idle">
|
||||
<motion.form
|
||||
onSubmit={onPromise(
|
||||
form.handleSubmit((data) =>
|
||||
signUp.mutateAsync({
|
||||
...data,
|
||||
name: generateName(data.email),
|
||||
callbackURL: redirectTo,
|
||||
}),
|
||||
),
|
||||
)}
|
||||
className="space-y-6"
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
disabled={isSubmitting}
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("password")}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
disabled={isSubmitting}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && provider === AuthProvider.PASSWORD ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("register.cta")
|
||||
)}
|
||||
</Button>
|
||||
</motion.form>
|
||||
</Form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
RegisterForm.displayName = "RegisterForm";
|
||||
|
||||
export const RegisterCta = () => {
|
||||
const { t } = useTranslation("auth");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center pt-2">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t("login.noAccount")}
|
||||
<TurboLink
|
||||
href={
|
||||
searchParams.size > 0
|
||||
? `${pathsConfig.auth.register}?${searchParams.toString()}`
|
||||
: pathsConfig.auth.register
|
||||
}
|
||||
className="hover:text-primary pl-2 font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("register.cta")}!
|
||||
</TurboLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
116
apps/web/src/modules/auth/form/social-providers.tsx
Normal file
116
apps/web/src/modules/auth/form/social-providers.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { memo } from "react";
|
||||
|
||||
import { SocialProvider as SocialProviderType } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { useAuthFormStore } from "~/modules/auth/form/store";
|
||||
|
||||
import { auth } from "../lib/api";
|
||||
|
||||
import type { AuthProvider } from "@turbostarter/auth";
|
||||
import type { Icon } from "@turbostarter/ui-web/icons";
|
||||
|
||||
interface SocialProvidersProps {
|
||||
readonly providers: SocialProviderType[];
|
||||
readonly redirectTo?: string;
|
||||
}
|
||||
|
||||
export const SocialIcons: Record<SocialProviderType, Icon> = {
|
||||
[SocialProviderType.GITHUB]: Icons.Github,
|
||||
[SocialProviderType.GOOGLE]: Icons.Google,
|
||||
[SocialProviderType.APPLE]: Icons.Apple,
|
||||
};
|
||||
|
||||
const SocialProvider = ({
|
||||
provider,
|
||||
isSubmitting,
|
||||
onClick,
|
||||
actualProvider,
|
||||
}: {
|
||||
provider: SocialProviderType;
|
||||
isSubmitting: boolean;
|
||||
onClick: () => void;
|
||||
actualProvider: AuthProvider;
|
||||
}) => {
|
||||
const { t } = useTranslation("common");
|
||||
const Icon = SocialIcons[provider];
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={provider}
|
||||
variant="outline"
|
||||
type="button"
|
||||
size="lg"
|
||||
className="relative grow basis-28 gap-2"
|
||||
disabled={isSubmitting}
|
||||
onClick={onClick}
|
||||
>
|
||||
{isSubmitting && actualProvider === provider ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Icon className="size-5 dark:brightness-125" />
|
||||
<span className="leading-none capitalize">{provider}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{authClient.isLastUsedLoginMethod(provider) && (
|
||||
<Badge className="absolute top-0 -right-4 z-10 -translate-y-1/2 shadow-sm">
|
||||
{t("lastUsed")}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const SocialProviders = memo<SocialProvidersProps>(
|
||||
({ providers, redirectTo = pathsConfig.dashboard.user.index }) => {
|
||||
const {
|
||||
provider: actualProvider,
|
||||
setProvider,
|
||||
isSubmitting,
|
||||
setIsSubmitting,
|
||||
} = useAuthFormStore();
|
||||
|
||||
const signIn = useMutation({
|
||||
...auth.mutations.signIn.social,
|
||||
onMutate: ({ provider }) => {
|
||||
setProvider(provider as SocialProviderType);
|
||||
setIsSubmitting(true);
|
||||
},
|
||||
onSettled: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.values(providers).map((provider) => (
|
||||
<SocialProvider
|
||||
key={provider}
|
||||
provider={provider}
|
||||
isSubmitting={isSubmitting}
|
||||
onClick={() =>
|
||||
signIn.mutate({
|
||||
provider,
|
||||
callbackURL: redirectTo,
|
||||
errorCallbackURL: pathsConfig.auth.error,
|
||||
})
|
||||
}
|
||||
actualProvider={actualProvider}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SocialProviders.displayName = "SocialProviders";
|
||||
15
apps/web/src/modules/auth/form/store/index.ts
Normal file
15
apps/web/src/modules/auth/form/store/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
import { AuthProvider } from "@turbostarter/auth";
|
||||
|
||||
export const useAuthFormStore = create<{
|
||||
provider: AuthProvider;
|
||||
setProvider: (provider: AuthProvider) => void;
|
||||
isSubmitting: boolean;
|
||||
setIsSubmitting: (isSubmitting: boolean) => void;
|
||||
}>((set) => ({
|
||||
provider: AuthProvider.PASSWORD,
|
||||
setProvider: (provider) => set({ provider }),
|
||||
isSubmitting: false,
|
||||
setIsSubmitting: (isSubmitting) => set({ isSubmitting }),
|
||||
}));
|
||||
127
apps/web/src/modules/auth/form/two-factor/backup-code.tsx
Normal file
127
apps/web/src/modules/auth/form/two-factor/backup-code.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { backupCodeVerificationSchema, SecondFactor } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Checkbox } from "@turbostarter/ui-web/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Input } from "@turbostarter/ui-web/input";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
import type { CtaProps, FormProps } from ".";
|
||||
|
||||
const BackupCodeForm = memo<FormProps>(
|
||||
({ redirectTo = pathsConfig.dashboard.user.index }) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(backupCodeVerificationSchema),
|
||||
defaultValues: {
|
||||
code: "",
|
||||
trustDevice: false,
|
||||
},
|
||||
});
|
||||
|
||||
const verifyBackupCode = useMutation({
|
||||
...auth.mutations.twoFactor.backupCodes.verify,
|
||||
onSuccess: () => {
|
||||
router.replace(redirectTo);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) =>
|
||||
verifyBackupCode.mutateAsync(data),
|
||||
)}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
autoFocus
|
||||
disabled={form.formState.isSubmitting}
|
||||
autoComplete="one-time-code"
|
||||
placeholder={t("login.twoFactor.backupCode.placeholder")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trustDevice"
|
||||
render={({ field }) => (
|
||||
<FormItem className="-mt-2 ml-px flex items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={form.formState.isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel>{t("login.twoFactor.trustDevice")}</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("verify")
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const BackupCodeCta = memo<CtaProps>(({ onFactorChange }) => {
|
||||
const { t } = useTranslation(["auth"]);
|
||||
return (
|
||||
<div className="flex items-center justify-center pt-2">
|
||||
<span
|
||||
role="link"
|
||||
onClick={() => onFactorChange(SecondFactor.BACKUP_CODE)}
|
||||
className="text-muted-foreground hover:text-primary cursor-pointer pl-2 text-sm font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("login.twoFactor.backupCode.cta")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export { BackupCodeForm, BackupCodeCta };
|
||||
28
apps/web/src/modules/auth/form/two-factor/index.tsx
Normal file
28
apps/web/src/modules/auth/form/two-factor/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { SecondFactor } from "@turbostarter/auth";
|
||||
|
||||
import { BackupCodeForm, BackupCodeCta } from "./backup-code";
|
||||
import { TotpForm, TotpCta } from "./totp";
|
||||
|
||||
export interface FormProps {
|
||||
readonly redirectTo?: string;
|
||||
}
|
||||
|
||||
export interface CtaProps {
|
||||
readonly onFactorChange: (factor: SecondFactor) => void;
|
||||
}
|
||||
|
||||
const TwoFactorForm: Record<
|
||||
SecondFactor,
|
||||
(props: FormProps) => React.ReactNode
|
||||
> = {
|
||||
[SecondFactor.TOTP]: TotpForm,
|
||||
[SecondFactor.BACKUP_CODE]: BackupCodeForm,
|
||||
};
|
||||
|
||||
const TwoFactorCta: Record<SecondFactor, (props: CtaProps) => React.ReactNode> =
|
||||
{
|
||||
[SecondFactor.TOTP]: TotpCta,
|
||||
[SecondFactor.BACKUP_CODE]: BackupCodeCta,
|
||||
};
|
||||
|
||||
export { TwoFactorForm, TwoFactorCta };
|
||||
137
apps/web/src/modules/auth/form/two-factor/totp.tsx
Normal file
137
apps/web/src/modules/auth/form/two-factor/totp.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { otpVerificationSchema, SecondFactor } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Checkbox } from "@turbostarter/ui-web/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@turbostarter/ui-web/input-otp";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
|
||||
import { auth } from "../../lib/api";
|
||||
|
||||
import type { CtaProps, FormProps } from ".";
|
||||
|
||||
const TotpForm = memo<FormProps>(
|
||||
({ redirectTo = pathsConfig.dashboard.user.index }) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(otpVerificationSchema),
|
||||
defaultValues: {
|
||||
code: "",
|
||||
trustDevice: false,
|
||||
},
|
||||
});
|
||||
|
||||
const verifyTotp = useMutation({
|
||||
...auth.mutations.twoFactor.totp.verify,
|
||||
onSuccess: () => {
|
||||
router.replace(redirectTo);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => verifyTotp.mutateAsync(data))}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
disabled={form.formState.isSubmitting}
|
||||
onComplete={form.handleSubmit((data) =>
|
||||
verifyTotp.mutateAsync(data),
|
||||
)}
|
||||
{...field}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<InputOTPSlot key={index} index={index} />
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trustDevice"
|
||||
render={({ field }) => (
|
||||
<FormItem className="-mt-2 ml-px flex items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={form.formState.isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel>{t("login.twoFactor.trustDevice")}</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("verify")
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const TotpCta = memo<CtaProps>(({ onFactorChange }) => {
|
||||
const { t } = useTranslation(["auth"]);
|
||||
return (
|
||||
<div className="flex items-center justify-center pt-2">
|
||||
<span
|
||||
role="link"
|
||||
onClick={() => onFactorChange(SecondFactor.TOTP)}
|
||||
className="text-muted-foreground hover:text-primary cursor-pointer pl-2 text-sm font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("login.twoFactor.totp.cta")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export { TotpForm, TotpCta };
|
||||
20
apps/web/src/modules/auth/layout/divider.tsx
Normal file
20
apps/web/src/modules/auth/layout/divider.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
|
||||
export const AuthDivider = () => {
|
||||
const { t } = useTranslation("auth");
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="border-input w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-background text-muted-foreground px-2 leading-tight">
|
||||
{t("divider")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
17
apps/web/src/modules/auth/layout/header.tsx
Normal file
17
apps/web/src/modules/auth/layout/header.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React, { memo } from "react";
|
||||
|
||||
interface AuthHeaderProps {
|
||||
readonly title: React.ReactNode;
|
||||
readonly description: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AuthHeader = memo<AuthHeaderProps>(({ title, description }) => {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tighter">{title}</h1>
|
||||
<p className="text-muted-foreground mt-2 text-sm">{description}</p>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AuthHeader.displayName = "AuthHeader";
|
||||
23
apps/web/src/modules/auth/layout/invitation-disclaimer.tsx
Normal file
23
apps/web/src/modules/auth/layout/invitation-disclaimer.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@turbostarter/ui-web/alert";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
export const InvitationDisclaimer = () => {
|
||||
const { t } = useTranslation("organization");
|
||||
|
||||
return (
|
||||
<Alert variant="primary">
|
||||
<Icons.MailPlus />
|
||||
<AlertTitle>{t("invitations.disclaimer.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("invitations.disclaimer.description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
207
apps/web/src/modules/auth/lib/api.ts
Normal file
207
apps/web/src/modules/auth/lib/api.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
|
||||
const KEY = "auth";
|
||||
|
||||
const queries = {
|
||||
sessions: {
|
||||
getAll: {
|
||||
queryKey: [KEY, "sessions"],
|
||||
queryFn: () =>
|
||||
authClient.listSessions({
|
||||
fetchOptions: {
|
||||
throw: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
getAll: {
|
||||
queryKey: [KEY, "accounts"],
|
||||
queryFn: () => authClient.listAccounts({ fetchOptions: { throw: true } }),
|
||||
},
|
||||
},
|
||||
passkeys: {
|
||||
getAll: {
|
||||
queryKey: [KEY, "passkeys"],
|
||||
queryFn: () =>
|
||||
authClient.passkey.listUserPasskeys({ fetchOptions: { throw: true } }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
signIn: {
|
||||
email: {
|
||||
mutationKey: [KEY, "signIn", "email"],
|
||||
mutationFn: (params: Parameters<typeof authClient.signIn.email>[0]) =>
|
||||
authClient.signIn.email(params),
|
||||
},
|
||||
magicLink: {
|
||||
mutationKey: [KEY, "signIn", "magicLink"],
|
||||
mutationFn: (params: Parameters<typeof authClient.signIn.magicLink>[0]) =>
|
||||
authClient.signIn.magicLink(params),
|
||||
},
|
||||
anonymous: {
|
||||
mutationKey: [KEY, "signIn", "anonymous"],
|
||||
mutationFn: (
|
||||
params?: Parameters<typeof authClient.signIn.anonymous>[0],
|
||||
) => authClient.signIn.anonymous(params),
|
||||
},
|
||||
social: {
|
||||
mutationKey: [KEY, "signIn", "social"],
|
||||
mutationFn: (params: Parameters<typeof authClient.signIn.social>[0]) =>
|
||||
authClient.signIn.social(params),
|
||||
},
|
||||
passkey: {
|
||||
mutationKey: [KEY, "signIn", "passkey"],
|
||||
mutationFn: (params?: Parameters<typeof authClient.signIn.passkey>[0]) =>
|
||||
authClient.signIn.passkey(params),
|
||||
},
|
||||
},
|
||||
password: {
|
||||
forget: {
|
||||
mutationKey: [KEY, "password", "forget"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.requestPasswordReset>[0],
|
||||
) => authClient.requestPasswordReset(params),
|
||||
},
|
||||
reset: {
|
||||
mutationKey: [KEY, "password", "update"],
|
||||
mutationFn: (params: Parameters<typeof authClient.resetPassword>[0]) =>
|
||||
authClient.resetPassword(params),
|
||||
},
|
||||
change: {
|
||||
mutationKey: [KEY, "password", "change"],
|
||||
mutationFn: (params: Parameters<typeof authClient.changePassword>[0]) =>
|
||||
authClient.changePassword(params),
|
||||
},
|
||||
},
|
||||
signOut: {
|
||||
mutationKey: [KEY, "signOut"],
|
||||
mutationFn: (params: Parameters<typeof authClient.signOut>[0]) =>
|
||||
authClient.signOut(params),
|
||||
},
|
||||
signUp: {
|
||||
email: {
|
||||
mutationKey: [KEY, "signUp", "email"],
|
||||
mutationFn: (params: Parameters<typeof authClient.signUp.email>[0]) =>
|
||||
authClient.signUp.email(params),
|
||||
},
|
||||
},
|
||||
twoFactor: {
|
||||
enable: {
|
||||
mutationKey: [KEY, "twoFactor", "enable"],
|
||||
mutationFn: (params: Parameters<typeof authClient.twoFactor.enable>[0]) =>
|
||||
authClient.twoFactor.enable({
|
||||
...params,
|
||||
fetchOptions: { throw: true },
|
||||
}),
|
||||
},
|
||||
disable: {
|
||||
mutationKey: [KEY, "twoFactor", "disable"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.twoFactor.disable>[0],
|
||||
) => authClient.twoFactor.disable(params),
|
||||
},
|
||||
backupCodes: {
|
||||
generate: {
|
||||
mutationKey: [KEY, "twoFactor", "backupCodes", "generate"],
|
||||
mutationFn: (
|
||||
params: Parameters<
|
||||
typeof authClient.twoFactor.generateBackupCodes
|
||||
>[0],
|
||||
) =>
|
||||
authClient.twoFactor.generateBackupCodes({
|
||||
...params,
|
||||
fetchOptions: {
|
||||
throw: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
verify: {
|
||||
mutationKey: [KEY, "twoFactor", "backupCodes", "verify"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.twoFactor.verifyBackupCode>[0],
|
||||
) => authClient.twoFactor.verifyBackupCode(params),
|
||||
},
|
||||
},
|
||||
totp: {
|
||||
getUri: {
|
||||
mutationKey: [KEY, "twoFactor", "totp", "getUri"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.twoFactor.getTotpUri>[0],
|
||||
) =>
|
||||
authClient.twoFactor.getTotpUri({
|
||||
...params,
|
||||
fetchOptions: { throw: true },
|
||||
}),
|
||||
},
|
||||
verify: {
|
||||
mutationKey: [KEY, "twoFactor", "totp", "verify"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.twoFactor.verifyTotp>[0],
|
||||
) => authClient.twoFactor.verifyTotp(params),
|
||||
},
|
||||
},
|
||||
},
|
||||
email: {
|
||||
sendVerification: {
|
||||
mutationKey: [KEY, "email", "sendVerification"],
|
||||
mutationFn: (
|
||||
params: Parameters<typeof authClient.sendVerificationEmail>[0],
|
||||
) => authClient.sendVerificationEmail(params),
|
||||
},
|
||||
change: {
|
||||
mutationKey: [KEY, "email", "change"],
|
||||
mutationFn: (params: Parameters<typeof authClient.changeEmail>[0]) =>
|
||||
authClient.changeEmail(params),
|
||||
},
|
||||
},
|
||||
sessions: {
|
||||
revoke: {
|
||||
mutationKey: [KEY, "sessions", "revoke"],
|
||||
mutationFn: (token: string) => authClient.revokeSession({ token }),
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
connect: {
|
||||
mutationKey: [KEY, "accounts", "connect"],
|
||||
mutationFn: (params: Parameters<typeof authClient.linkSocial>[0]) =>
|
||||
authClient.linkSocial(params),
|
||||
},
|
||||
disconnect: {
|
||||
mutationKey: [KEY, "accounts", "disconnect"],
|
||||
mutationFn: (params: Parameters<typeof authClient.unlinkAccount>[0]) =>
|
||||
authClient.unlinkAccount(params),
|
||||
},
|
||||
},
|
||||
passkeys: {
|
||||
add: {
|
||||
mutationKey: [KEY, "passkeys", "add"],
|
||||
mutationFn: async () => {
|
||||
const response = await authClient.passkey.addPasskey();
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
mutationKey: [KEY, "passkeys", "delete"],
|
||||
mutationFn: async (
|
||||
params: Parameters<typeof authClient.passkey.deletePasskey>[0],
|
||||
) => {
|
||||
const response = await authClient.passkey.deletePasskey(params);
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const auth = {
|
||||
queries,
|
||||
mutations,
|
||||
};
|
||||
134
apps/web/src/modules/auth/login.tsx
Normal file
134
apps/web/src/modules/auth/login.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState } from "react";
|
||||
|
||||
import { SecondFactor } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
|
||||
import { authConfig } from "~/config/auth";
|
||||
|
||||
import { AnonymousLogin } from "./form/anonymous";
|
||||
import { LOGIN_OPTIONS } from "./form/login/constants";
|
||||
import { LoginForm } from "./form/login/form";
|
||||
import { PasskeyLogin } from "./form/login/passkey";
|
||||
import { RegisterCta } from "./form/register-form";
|
||||
import { SocialProviders } from "./form/social-providers";
|
||||
import { TwoFactorForm } from "./form/two-factor";
|
||||
import { TwoFactorCta } from "./form/two-factor";
|
||||
import { AuthDivider } from "./layout/divider";
|
||||
import { AuthHeader } from "./layout/header";
|
||||
import { InvitationDisclaimer } from "./layout/invitation-disclaimer";
|
||||
|
||||
import type { LoginOption } from "./form/login/constants";
|
||||
|
||||
const LoginStep = {
|
||||
FORM: "form",
|
||||
TWO_FACTOR: "twoFactor",
|
||||
} as const;
|
||||
|
||||
type LoginStep = (typeof LoginStep)[keyof typeof LoginStep];
|
||||
|
||||
interface LoginFlowProps {
|
||||
readonly redirectTo?: string;
|
||||
readonly invitationId?: string;
|
||||
readonly email?: string;
|
||||
}
|
||||
|
||||
export const LoginFlow = ({
|
||||
redirectTo,
|
||||
invitationId,
|
||||
email,
|
||||
}: LoginFlowProps) => {
|
||||
const [step, setStep] = useState<LoginStep>(LoginStep.FORM);
|
||||
|
||||
switch (step) {
|
||||
case LoginStep.FORM:
|
||||
return (
|
||||
<Login
|
||||
redirectTo={redirectTo}
|
||||
invitationId={invitationId}
|
||||
email={email}
|
||||
onTwoFactorRedirect={() => setStep(LoginStep.TWO_FACTOR)}
|
||||
/>
|
||||
);
|
||||
case LoginStep.TWO_FACTOR:
|
||||
return <TwoFactor redirectTo={redirectTo} />;
|
||||
}
|
||||
};
|
||||
|
||||
interface LoginProps extends LoginFlowProps {
|
||||
readonly onTwoFactorRedirect?: () => void;
|
||||
}
|
||||
|
||||
const Login = memo<LoginProps>(
|
||||
({ redirectTo, invitationId, email, onTwoFactorRedirect }) => {
|
||||
const { t } = useTranslation("auth");
|
||||
const options = Object.entries(authConfig.providers)
|
||||
.filter(
|
||||
([provider, enabled]) =>
|
||||
enabled && LOGIN_OPTIONS.includes(provider as LoginOption),
|
||||
)
|
||||
.map(([provider]) => provider as LoginOption);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader
|
||||
title={t("login.header.title")}
|
||||
description={t("login.header.description")}
|
||||
/>
|
||||
|
||||
{invitationId && <InvitationDisclaimer />}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<SocialProviders
|
||||
providers={authConfig.providers.oAuth}
|
||||
redirectTo={redirectTo}
|
||||
/>
|
||||
{authConfig.providers.passkey && (
|
||||
<PasskeyLogin redirectTo={redirectTo} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(authConfig.providers.oAuth.length > 0 ||
|
||||
authConfig.providers.passkey) &&
|
||||
options.length > 0 && <AuthDivider />}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<LoginForm
|
||||
options={options}
|
||||
redirectTo={redirectTo}
|
||||
email={email}
|
||||
onTwoFactorRedirect={onTwoFactorRedirect}
|
||||
/>
|
||||
|
||||
{authConfig.providers.anonymous && <AnonymousLogin />}
|
||||
</div>
|
||||
|
||||
<RegisterCta />
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const TwoFactor = memo<LoginFlowProps>(({ redirectTo }) => {
|
||||
const [factor, setFactor] = useState<SecondFactor>(SecondFactor.TOTP);
|
||||
const { t } = useTranslation("auth");
|
||||
|
||||
const Form = TwoFactorForm[factor];
|
||||
const Cta =
|
||||
factor === SecondFactor.TOTP
|
||||
? TwoFactorCta[SecondFactor.BACKUP_CODE]
|
||||
: TwoFactorCta[SecondFactor.TOTP];
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader
|
||||
title={t(`login.twoFactor.${factor}.header.title`)}
|
||||
description={t(`login.twoFactor.${factor}.header.description`)}
|
||||
/>
|
||||
|
||||
<Form redirectTo={redirectTo} />
|
||||
<Cta onFactorChange={setFactor} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
5
apps/web/src/modules/billing/hooks/use-customer.ts
Normal file
5
apps/web/src/modules/billing/hooks/use-customer.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { billing } from "~/modules/billing/lib/api";
|
||||
|
||||
export const useCustomer = () => useQuery(billing.queries.customer.get);
|
||||
38
apps/web/src/modules/billing/lib/api.ts
Normal file
38
apps/web/src/modules/billing/lib/api.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
import type { InferRequestType } from "hono/client";
|
||||
|
||||
const KEY = "billing";
|
||||
|
||||
const queries = {
|
||||
customer: {
|
||||
get: {
|
||||
queryKey: [KEY, "customer"],
|
||||
queryFn: () => handle(api.billing.customer.$get)(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
portal: {
|
||||
get: {
|
||||
mutationKey: [KEY, "portal"],
|
||||
mutationFn: (data: InferRequestType<typeof api.billing.portal.$get>) =>
|
||||
handle(api.billing.portal.$get)(data),
|
||||
},
|
||||
},
|
||||
checkout: {
|
||||
create: {
|
||||
mutationKey: [KEY, "checkout"],
|
||||
mutationFn: (data: InferRequestType<typeof api.billing.checkout.$post>) =>
|
||||
handle(api.billing.checkout.$post)(data),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const billing = {
|
||||
queries,
|
||||
mutations,
|
||||
};
|
||||
142
apps/web/src/modules/billing/pricing/constants/features.tsx
Normal file
142
apps/web/src/modules/billing/pricing/constants/features.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { PricingPlanType, FEATURES } from "@turbostarter/billing";
|
||||
|
||||
interface PlanFeature {
|
||||
readonly id: string;
|
||||
readonly available: boolean;
|
||||
readonly title: string;
|
||||
readonly addon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PLAN_FEATURES: Record<PricingPlanType, PlanFeature[]> = {
|
||||
[PricingPlanType.FREE]: [
|
||||
{
|
||||
id: FEATURES[PricingPlanType.FREE].SYNC,
|
||||
available: true,
|
||||
title: "billing:plan.starter.features.sync",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.FREE].BASIC_SUPPORT,
|
||||
available: true,
|
||||
title: "billing:plan.starter.features.basicSupport",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.FREE].LIMITED_STORAGE,
|
||||
available: true,
|
||||
title: "billing:plan.starter.features.limitedStorage",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.FREE].EMAIL_NOTIFICATIONS,
|
||||
available: true,
|
||||
title: "billing:plan.starter.features.emailNotifications",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.FREE].BASIC_REPORTS,
|
||||
available: true,
|
||||
title: "billing:plan.starter.features.basicReports",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.PREMIUM].ADVANCED_SYNC,
|
||||
available: false,
|
||||
title: "billing:plan.premium.features.advancedSync",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.PREMIUM].PRIORITY_SUPPORT,
|
||||
available: false,
|
||||
title: "billing:plan.premium.features.prioritySupport",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.PREMIUM].MORE_STORAGE,
|
||||
available: false,
|
||||
title: "billing:plan.premium.features.moreStorage",
|
||||
},
|
||||
],
|
||||
[PricingPlanType.PREMIUM]: [
|
||||
{
|
||||
id: FEATURES[PricingPlanType.PREMIUM].ADVANCED_SYNC,
|
||||
available: true,
|
||||
title: "billing:plan.premium.features.advancedSync",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.PREMIUM].PRIORITY_SUPPORT,
|
||||
available: true,
|
||||
title: "billing:plan.premium.features.prioritySupport",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.PREMIUM].MORE_STORAGE,
|
||||
available: true,
|
||||
title: "billing:plan.premium.features.moreStorage",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.PREMIUM].TEAM_COLLABORATION,
|
||||
available: true,
|
||||
title: "billing:plan.premium.features.teamCollaboration",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.PREMIUM].SMS_NOTIFICATIONS,
|
||||
available: true,
|
||||
title: "billing:plan.premium.features.smsNotifications",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.PREMIUM].ADVANCED_REPORTS,
|
||||
available: true,
|
||||
title: "billing:plan.premium.features.advancedReports",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].UNLIMITED_STORAGE,
|
||||
available: false,
|
||||
title: "billing:plan.enterprise.features.unlimitedStorage",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].CUSTOM_BRANDING,
|
||||
available: false,
|
||||
title: "billing:plan.enterprise.features.customBranding",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].DEDICATED_SUPPORT,
|
||||
available: false,
|
||||
title: "billing:plan.enterprise.features.dedicatedSupport",
|
||||
},
|
||||
],
|
||||
[PricingPlanType.ENTERPRISE]: [
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].UNLIMITED_STORAGE,
|
||||
available: true,
|
||||
title: "billing:plan.enterprise.features.unlimitedStorage",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].CUSTOM_BRANDING,
|
||||
available: true,
|
||||
title: "billing:plan.enterprise.features.customBranding",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].DEDICATED_SUPPORT,
|
||||
available: true,
|
||||
title: "billing:plan.enterprise.features.dedicatedSupport",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].API_ACCESS,
|
||||
available: true,
|
||||
title: "billing:plan.enterprise.features.apiAccess",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].USER_ROLES,
|
||||
available: true,
|
||||
title: "billing:plan.enterprise.features.userRoles",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].AUDIT_LOGS,
|
||||
available: true,
|
||||
title: "billing:plan.enterprise.features.auditLogs",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].SINGLE_SIGN_ON,
|
||||
available: true,
|
||||
title: "billing:plan.enterprise.features.singleSignOn",
|
||||
},
|
||||
{
|
||||
id: FEATURES[PricingPlanType.ENTERPRISE].ADVANCED_ANALYTICS,
|
||||
available: true,
|
||||
title: "billing:plan.enterprise.features.advancedAnalytics",
|
||||
},
|
||||
],
|
||||
};
|
||||
66
apps/web/src/modules/billing/pricing/layout/discount.tsx
Normal file
66
apps/web/src/modules/billing/pricing/layout/discount.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
import {
|
||||
calculatePriceDiscount,
|
||||
formatPrice,
|
||||
BillingDiscountType,
|
||||
} from "@turbostarter/billing";
|
||||
import { Trans } from "@turbostarter/i18n";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import type {
|
||||
Discount as DiscountType,
|
||||
PricingPlanPrice,
|
||||
} from "@turbostarter/billing";
|
||||
|
||||
interface DiscountProps {
|
||||
readonly currency: string;
|
||||
readonly priceWithDiscount?: PricingPlanPrice & {
|
||||
discount: DiscountType | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export const Discount = memo<DiscountProps>(
|
||||
({ priceWithDiscount, currency }) => {
|
||||
if (!priceWithDiscount?.discount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const discount = calculatePriceDiscount(
|
||||
priceWithDiscount,
|
||||
priceWithDiscount.discount,
|
||||
);
|
||||
|
||||
if (!discount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="sm mt-2 text-center md:text-lg">
|
||||
<Icons.Gift className="text-primary mr-1.5 mb-1.5 inline-block h-5 w-5" />
|
||||
<span className="text-primary">
|
||||
<Trans
|
||||
i18nKey="billing:discount.specialOffer"
|
||||
values={{
|
||||
discount:
|
||||
discount.type === BillingDiscountType.PERCENT
|
||||
? discount.percentage + "%"
|
||||
: formatPrice({
|
||||
amount:
|
||||
discount.original.amount - discount.discounted.amount,
|
||||
currency,
|
||||
}),
|
||||
}}
|
||||
components={{
|
||||
bold: <span className="font-semibold" />,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Discount.displayName = "Discount";
|
||||
88
apps/web/src/modules/billing/pricing/layout/header.tsx
Normal file
88
apps/web/src/modules/billing/pricing/layout/header.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
import { BillingModel } from "@turbostarter/billing";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@turbostarter/ui-web/tabs";
|
||||
|
||||
import {
|
||||
SectionBadge,
|
||||
SectionDescription,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "~/modules/marketing/layout/section";
|
||||
|
||||
import { Discount } from "./discount";
|
||||
|
||||
import type {
|
||||
Discount as DiscountType,
|
||||
PricingPlanPrice,
|
||||
RecurringInterval,
|
||||
} from "@turbostarter/billing";
|
||||
|
||||
interface PricingHeaderProps {
|
||||
readonly currency: string;
|
||||
readonly model: BillingModel;
|
||||
readonly intervals: RecurringInterval[];
|
||||
readonly activeInterval: RecurringInterval;
|
||||
readonly onIntervalChange: (billing: RecurringInterval) => void;
|
||||
readonly priceWithDiscount?: PricingPlanPrice & {
|
||||
discount: DiscountType | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export const PricingHeader = memo<PricingHeaderProps>(
|
||||
({
|
||||
model,
|
||||
activeInterval,
|
||||
intervals,
|
||||
onIntervalChange,
|
||||
priceWithDiscount,
|
||||
currency,
|
||||
}) => {
|
||||
const { t } = useTranslation("billing");
|
||||
|
||||
return (
|
||||
<SectionHeader>
|
||||
<SectionBadge>{t("pricing.label")}</SectionBadge>
|
||||
<SectionTitle>{t("pricing.title")}</SectionTitle>
|
||||
<SectionDescription className="text-muted-foreground max-w-2xl text-center">
|
||||
{t("pricing.description")}
|
||||
</SectionDescription>
|
||||
|
||||
<Discount
|
||||
{...(priceWithDiscount && {
|
||||
priceWithDiscount,
|
||||
})}
|
||||
currency={currency}
|
||||
/>
|
||||
|
||||
{model === BillingModel.RECURRING && intervals.length > 0 && (
|
||||
<Tabs
|
||||
className="mt-2 lg:mt-4"
|
||||
value={activeInterval}
|
||||
onValueChange={(value) =>
|
||||
onIntervalChange(value as RecurringInterval)
|
||||
}
|
||||
>
|
||||
<TabsList>
|
||||
{intervals.map((interval) => (
|
||||
<TabsTrigger
|
||||
key={interval}
|
||||
value={interval}
|
||||
className="capitalize"
|
||||
aria-controls={undefined}
|
||||
>
|
||||
{t(`interval.${interval}`)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)}
|
||||
</SectionHeader>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PricingHeader.displayName = "PricingHeader";
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
BillingModel,
|
||||
PricingPlanType,
|
||||
calculatePriceDiscount,
|
||||
calculateRecurringDiscount,
|
||||
getPlanPrice,
|
||||
getHighestDiscountForPrice,
|
||||
} from "@turbostarter/billing";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
|
||||
import { appConfig } from "~/config/app";
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { billing } from "~/modules/billing/lib/api";
|
||||
import { PLAN_FEATURES } from "~/modules/billing/pricing/constants/features";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
import type {
|
||||
Discount,
|
||||
PricingPlan,
|
||||
RecurringInterval,
|
||||
} from "@turbostarter/billing";
|
||||
|
||||
export const usePlan = (
|
||||
plan: PricingPlan,
|
||||
options: {
|
||||
model: BillingModel;
|
||||
interval: RecurringInterval;
|
||||
discounts: Discount[];
|
||||
currency?: string;
|
||||
},
|
||||
) => {
|
||||
const { t } = useTranslation("billing");
|
||||
const router = useRouter();
|
||||
const checkout = useMutation({
|
||||
...billing.mutations.checkout.create,
|
||||
onSuccess: (data) => {
|
||||
if (!data.url) {
|
||||
return toast.error(t("error.checkout"));
|
||||
}
|
||||
return router.push(data.url);
|
||||
},
|
||||
});
|
||||
|
||||
const getPortal = useMutation({
|
||||
...billing.mutations.portal.get,
|
||||
onSuccess: (data) => {
|
||||
if (!data.url) {
|
||||
return toast.error(t("error.portal"));
|
||||
}
|
||||
return router.push(data.url);
|
||||
},
|
||||
});
|
||||
|
||||
const pathname = usePathname();
|
||||
const price = getPlanPrice(plan, options);
|
||||
|
||||
const features = plan.id in PLAN_FEATURES ? PLAN_FEATURES[plan.id] : null;
|
||||
|
||||
const discountForPrice = price
|
||||
? getHighestDiscountForPrice(price, options.discounts)
|
||||
: null;
|
||||
|
||||
const discount =
|
||||
price && discountForPrice
|
||||
? calculatePriceDiscount(price, discountForPrice)
|
||||
: options.model === BillingModel.RECURRING
|
||||
? calculateRecurringDiscount(plan, options.interval)
|
||||
: null;
|
||||
|
||||
const handleCheckout = (user: User | null) => {
|
||||
if (!user) {
|
||||
const url = new URL(pathsConfig.auth.login);
|
||||
url.searchParams.set("redirectTo", pathsConfig.marketing.pricing);
|
||||
return router.push(url.toString());
|
||||
}
|
||||
|
||||
if (!price) {
|
||||
return;
|
||||
}
|
||||
|
||||
checkout.mutate({
|
||||
json: {
|
||||
price: {
|
||||
id: price.id,
|
||||
},
|
||||
redirect: {
|
||||
success: `${appConfig.url}${pathsConfig.dashboard.user.index}`,
|
||||
cancel: `${appConfig.url}${pathname}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenPortal = (user: User | null) => {
|
||||
if (!user) {
|
||||
const url = new URL(pathsConfig.auth.login);
|
||||
url.searchParams.set("redirectTo", pathsConfig.marketing.pricing);
|
||||
return router.push(url.toString());
|
||||
}
|
||||
|
||||
getPortal.mutate({
|
||||
query: {
|
||||
redirectUrl: `${appConfig.url}${pathname}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const hasPlan = (customerPlan: string | null) => {
|
||||
if (!customerPlan) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentPlanIndex = Object.values(PricingPlanType).indexOf(plan.id);
|
||||
const customerCurrentPlanIndex = customerPlan
|
||||
? Object.values(PricingPlanType).indexOf(customerPlan)
|
||||
: -1;
|
||||
|
||||
return currentPlanIndex <= customerCurrentPlanIndex;
|
||||
};
|
||||
|
||||
return {
|
||||
isPending: checkout.isPending || getPortal.isPending,
|
||||
price,
|
||||
features,
|
||||
discount,
|
||||
handleCheckout,
|
||||
handleOpenPortal,
|
||||
hasPlan,
|
||||
};
|
||||
};
|
||||
212
apps/web/src/modules/billing/pricing/plans/plan/plan.tsx
Normal file
212
apps/web/src/modules/billing/pricing/plans/plan/plan.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { memo } from "react";
|
||||
|
||||
import { BillingModel, formatPrice } from "@turbostarter/billing";
|
||||
import { isKey, useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Button, buttonVariants } from "@turbostarter/ui-web/button";
|
||||
import { Card } from "@turbostarter/ui-web/card";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { useCustomer } from "~/modules/billing/hooks/use-customer";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
import { usePlan } from "./hooks/use-plan";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
import type {
|
||||
Discount,
|
||||
PricingPlan,
|
||||
RecurringInterval,
|
||||
} from "@turbostarter/billing";
|
||||
|
||||
interface PlanProps {
|
||||
readonly plan: PricingPlan;
|
||||
readonly user: User | null;
|
||||
readonly interval: RecurringInterval;
|
||||
readonly model: BillingModel;
|
||||
readonly currency: string;
|
||||
readonly discounts: Discount[];
|
||||
}
|
||||
|
||||
export const Plan = memo<PlanProps>(
|
||||
({ plan, interval, user, model, currency, discounts }) => {
|
||||
const { data: customer } = useCustomer();
|
||||
const { t, i18n } = useTranslation(["common", "billing"]);
|
||||
const {
|
||||
features,
|
||||
price,
|
||||
discount,
|
||||
isPending,
|
||||
handleCheckout,
|
||||
handleOpenPortal,
|
||||
hasPlan,
|
||||
} = usePlan(plan, { model, interval, discounts, currency });
|
||||
|
||||
if (!price) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grow basis-[350px] rounded-lg">
|
||||
<Card
|
||||
className={cn(
|
||||
"relative flex h-full flex-col gap-6 px-7 py-6 md:p-8",
|
||||
plan.badge && "border-primary",
|
||||
)}
|
||||
>
|
||||
{plan.badge && (
|
||||
<Badge className="hover:bg-primary absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 px-4 py-1.5 uppercase">
|
||||
{isKey(plan.badge, i18n, "billing") ? t(plan.badge) : plan.badge}
|
||||
</Badge>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-lg font-semibold">
|
||||
{isKey(plan.name, i18n, "billing") ? t(plan.name) : plan.name}
|
||||
</span>
|
||||
<p className="relative flex items-end gap-1 py-2">
|
||||
{discount?.original &&
|
||||
"amount" in discount.original &&
|
||||
typeof discount.original.amount === "number" &&
|
||||
discount.percentage > 0 && (
|
||||
<span className="text-muted-foreground mr-2 text-lg line-through md:text-xl">
|
||||
{formatPrice(
|
||||
{
|
||||
amount: discount.original.amount,
|
||||
currency,
|
||||
},
|
||||
i18n.language,
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-4xl font-bold tracking-tighter md:text-5xl">
|
||||
{price.custom
|
||||
? isKey(price.label, i18n, "billing")
|
||||
? t(price.label)
|
||||
: price.label
|
||||
: formatPrice(
|
||||
{
|
||||
amount:
|
||||
discount?.discounted &&
|
||||
"amount" in discount.discounted
|
||||
? discount.discounted.amount
|
||||
: price.amount,
|
||||
currency,
|
||||
},
|
||||
i18n.language,
|
||||
)}
|
||||
</span>
|
||||
{!price.custom && (
|
||||
<span className="text-muted-foreground shrink-0 text-lg">
|
||||
/{" "}
|
||||
{price.type === BillingModel.RECURRING
|
||||
? t(`interval.${price.interval}`)
|
||||
: t("interval.lifetime")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<span className="text-sm">
|
||||
{isKey(plan.description, i18n, "billing")
|
||||
? t(plan.description)
|
||||
: plan.description}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
{features?.map((feature) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className={cn("flex items-center gap-3 py-1", {
|
||||
"opacity-50": !feature.available,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-5 shrink-0 items-center justify-center rounded-full",
|
||||
feature.available ? "bg-primary" : "border-primary border",
|
||||
)}
|
||||
>
|
||||
{feature.available ? (
|
||||
<Icons.CheckIcon className="text-primary-foreground w-3" />
|
||||
) : (
|
||||
<Icons.X className="text-primary w-3" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-md">
|
||||
{isKey(feature.title, i18n, "billing")
|
||||
? t(feature.title)
|
||||
: feature.title}
|
||||
{"addon" in feature && (
|
||||
<span className="ml-2 whitespace-nowrap">
|
||||
{feature.addon}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-2">
|
||||
{"trialDays" in price &&
|
||||
price.trialDays &&
|
||||
!hasPlan(customer?.plan ?? null) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleCheckout(user)}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("trial.period", { period: price.trialDays })
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{price.custom ? (
|
||||
<TurboLink href={price.href} className={buttonVariants()}>
|
||||
{hasPlan(customer?.plan ?? null)
|
||||
? t("manage.plan.title")
|
||||
: t("getStarted")}
|
||||
</TurboLink>
|
||||
) : price.amount === 0 ? (
|
||||
<TurboLink
|
||||
href={
|
||||
user
|
||||
? pathsConfig.dashboard.user.index
|
||||
: pathsConfig.auth.login
|
||||
}
|
||||
className={buttonVariants({ variant: "outline" })}
|
||||
>
|
||||
{user ? t("goToDashboard") : t("trial.cta")}
|
||||
</TurboLink>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() =>
|
||||
model === BillingModel.RECURRING &&
|
||||
hasPlan(customer?.plan ?? null)
|
||||
? handleOpenPortal(user)
|
||||
: handleCheckout(user)
|
||||
}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : model === BillingModel.RECURRING &&
|
||||
hasPlan(customer?.plan ?? null) ? (
|
||||
t("manage.plan.title")
|
||||
) : model === BillingModel.RECURRING ? (
|
||||
t("subscribe")
|
||||
) : (
|
||||
t("getLifetimeAccess")
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Plan.displayName = "Plan";
|
||||
56
apps/web/src/modules/billing/pricing/plans/plans.tsx
Normal file
56
apps/web/src/modules/billing/pricing/plans/plans.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { memo } from "react";
|
||||
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
|
||||
import { Plan } from "./plan/plan";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
import type {
|
||||
BillingModel,
|
||||
Discount,
|
||||
PricingPlan,
|
||||
RecurringInterval,
|
||||
} from "@turbostarter/billing";
|
||||
|
||||
interface PlansProps {
|
||||
readonly plans: PricingPlan[];
|
||||
readonly discounts: Discount[];
|
||||
readonly user: User | null;
|
||||
readonly interval: RecurringInterval;
|
||||
readonly model: BillingModel;
|
||||
readonly currency: string;
|
||||
}
|
||||
|
||||
export const Plans = memo<PlansProps>(
|
||||
({ plans, discounts, interval, user, model, currency }) => {
|
||||
return (
|
||||
<div className="flex w-full flex-wrap items-stretch justify-center gap-8 md:gap-6 lg:gap-4">
|
||||
{plans.map((plan) => (
|
||||
<Plan
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
interval={interval}
|
||||
model={model}
|
||||
currency={currency}
|
||||
user={user}
|
||||
discounts={discounts}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const PlansSkeleton = () => {
|
||||
return (
|
||||
<div className="flex w-full flex-wrap items-center justify-center gap-12 md:gap-6 lg:gap-4">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className="grow-0 basis-[25rem] md:shrink-0">
|
||||
<Skeleton className="h-[32rem] w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Plans.displayName = "Plans";
|
||||
30
apps/web/src/modules/billing/pricing/pricing.tsx
Normal file
30
apps/web/src/modules/billing/pricing/pricing.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
|
||||
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
import { env } from "@turbostarter/billing/env";
|
||||
|
||||
import { api } from "~/lib/api/server";
|
||||
import { getSession } from "~/lib/auth/server";
|
||||
import { getQueryClient } from "~/lib/query/server";
|
||||
import { billing } from "~/modules/billing/lib/api";
|
||||
|
||||
import { PricingSection } from "./section";
|
||||
|
||||
export const Pricing = async () => {
|
||||
const { user } = await getSession();
|
||||
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
if (user) {
|
||||
await queryClient.prefetchQuery({
|
||||
...billing.queries.customer.get,
|
||||
queryFn: handle(api.billing.customer.$get),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<PricingSection user={user} model={env.BILLING_MODEL} />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
};
|
||||
84
apps/web/src/modules/billing/pricing/section.tsx
Normal file
84
apps/web/src/modules/billing/pricing/section.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState } from "react";
|
||||
|
||||
import {
|
||||
RecurringInterval,
|
||||
RecurringIntervalDuration,
|
||||
config,
|
||||
getPriceWithHighestDiscount,
|
||||
} from "@turbostarter/billing";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
|
||||
import { Section, SectionHeader } from "~/modules/marketing/layout/section";
|
||||
|
||||
import { PricingHeader } from "./layout/header";
|
||||
import { Plans, PlansSkeleton } from "./plans/plans";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
import type { BillingModel } from "@turbostarter/billing";
|
||||
|
||||
interface PricingSectionProps {
|
||||
readonly user: User | null;
|
||||
readonly model: BillingModel;
|
||||
}
|
||||
|
||||
export const PricingSection = memo<PricingSectionProps>(({ user, model }) => {
|
||||
const { t } = useTranslation("billing");
|
||||
|
||||
const intervals = [
|
||||
...new Set(
|
||||
config.plans.flatMap((plan) =>
|
||||
plan.prices
|
||||
.flatMap((price) => ("interval" in price ? price.interval : null))
|
||||
.filter((x): x is RecurringInterval => !!x),
|
||||
),
|
||||
),
|
||||
].sort((a, b) => RecurringIntervalDuration[a] - RecurringIntervalDuration[b]);
|
||||
|
||||
const [activeInterval, setActiveInterval] = useState<RecurringInterval>(
|
||||
intervals[0] ?? RecurringInterval.MONTH,
|
||||
);
|
||||
|
||||
const priceWithDiscount = getPriceWithHighestDiscount(
|
||||
config.plans,
|
||||
config.discounts,
|
||||
);
|
||||
|
||||
return (
|
||||
<Section id="pricing" className="gap-10 sm:gap-12 md:gap-16 lg:gap-20">
|
||||
<PricingHeader
|
||||
currency={t("currency")}
|
||||
model={model}
|
||||
intervals={intervals}
|
||||
activeInterval={activeInterval}
|
||||
onIntervalChange={setActiveInterval}
|
||||
{...(priceWithDiscount && { priceWithDiscount })}
|
||||
/>
|
||||
<Plans
|
||||
plans={config.plans}
|
||||
interval={activeInterval}
|
||||
model={model}
|
||||
currency={t("currency")}
|
||||
discounts={config.discounts}
|
||||
user={user}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
});
|
||||
|
||||
export const PricingSectionSkeleton = () => {
|
||||
return (
|
||||
<Section id="pricing" className="gap-10 sm:gap-12 md:gap-16 lg:gap-20">
|
||||
<SectionHeader className="flex flex-col items-center justify-center gap-3">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<Skeleton className="mt-4 h-12 w-72" />
|
||||
<Skeleton className="h-8 w-96" />
|
||||
</SectionHeader>
|
||||
<PlansSkeleton />
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
PricingSection.displayName = "PricingSection";
|
||||
125
apps/web/src/modules/chat/composer/components/voice-button.tsx
Normal file
125
apps/web/src/modules/chat/composer/components/voice-button.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import type { VoiceButtonProps } from "../types";
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const VoiceLevelBars = ({ level }: { level: number }) => {
|
||||
// Create 3 bars with different thresholds
|
||||
const bars = [
|
||||
{ threshold: 10, delay: "0ms" },
|
||||
{ threshold: 30, delay: "100ms" },
|
||||
{ threshold: 50, delay: "200ms" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-0.5 h-3">
|
||||
{bars.map((bar, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"w-0.5 bg-white rounded-full transition-all duration-150",
|
||||
level > bar.threshold ? "opacity-100" : "opacity-30"
|
||||
)}
|
||||
style={{
|
||||
height: level > bar.threshold ? `${Math.min(12, 4 + (level / 100) * 8)}px` : "4px",
|
||||
animationDelay: bar.delay,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const VoiceButton = ({
|
||||
state,
|
||||
duration,
|
||||
audioLevel,
|
||||
disabled = false,
|
||||
onToggle,
|
||||
onCancel: _onCancel,
|
||||
}: VoiceButtonProps) => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
const isRecording = state === "recording";
|
||||
const isProcessing = state === "processing";
|
||||
|
||||
const getTooltipContent = () => {
|
||||
if (isRecording) {
|
||||
return t("pressEscapeToCancel");
|
||||
}
|
||||
if (isProcessing) {
|
||||
return t("transcribing");
|
||||
}
|
||||
return t("record");
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="relative">
|
||||
{/* Recording state indicator - shows duration and level */}
|
||||
{isRecording && (
|
||||
<div className="absolute -top-8 left-1/2 -translate-x-1/2 flex items-center gap-1.5 bg-destructive text-destructive-foreground px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-white" />
|
||||
</span>
|
||||
<span>{formatDuration(duration)}</span>
|
||||
<VoiceLevelBars level={audioLevel} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className={cn(
|
||||
"shrink-0 rounded-full transition-all duration-200",
|
||||
isRecording && "bg-destructive hover:bg-destructive/90 text-destructive-foreground animate-pulse-ring",
|
||||
isProcessing && "opacity-70"
|
||||
)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant={isRecording ? "destructive" : "ghost"}
|
||||
onClick={onToggle}
|
||||
disabled={disabled || isProcessing}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Icons.Loader2 className="size-4 animate-spin" />
|
||||
<span className="sr-only">{t("transcribing")}</span>
|
||||
</>
|
||||
) : isRecording ? (
|
||||
<>
|
||||
<Icons.Square className="size-3.5 fill-current" />
|
||||
<span className="sr-only">{t("stop")}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icons.Mic className="size-4" />
|
||||
<span className="sr-only">{t("record")}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{getTooltipContent()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceButton;
|
||||
56
apps/web/src/modules/chat/composer/dropzone.tsx
Normal file
56
apps/web/src/modules/chat/composer/dropzone.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { memo } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { Attachments } from "~/modules/common/ai/composer/attachments";
|
||||
|
||||
import { useAttachments } from "./hooks/use-attachments";
|
||||
|
||||
const DropzoneDialog = () => {
|
||||
const { t } = useTranslation("ai");
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-background relative z-10 mx-6 flex flex-col items-center justify-center rounded-xl border p-6 py-8 sm:p-8 sm:py-10 md:px-12 md:py-10"
|
||||
initial={{ opacity: 0, translateY: 10 }}
|
||||
animate={{ opacity: 1, translateY: 0 }}
|
||||
exit={{ opacity: 0, translateY: 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Icons.ImagePlus className="text-muted-foreground size-12" />
|
||||
<span className="mt-3 text-lg font-medium">
|
||||
{t("chat.composer.files.dropzone.title")}
|
||||
</span>
|
||||
<p className="text-muted-foreground text-center">
|
||||
{t("chat.composer.files.dropzone.description")}
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ChatDropzoneProps {
|
||||
readonly children: React.ReactNode;
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ChatDropzone = memo<ChatDropzoneProps>(
|
||||
({ children, disabled }) => {
|
||||
const { onAdd } = useAttachments();
|
||||
|
||||
return (
|
||||
<Attachments.Dropzone
|
||||
onDrop={onAdd}
|
||||
dialog={<DropzoneDialog />}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</Attachments.Dropzone>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ChatDropzone.displayName = "ChatDropzone";
|
||||
148
apps/web/src/modules/chat/composer/hooks/use-attachments.tsx
Normal file
148
apps/web/src/modules/chat/composer/hooks/use-attachments.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { create } from "zustand";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { generateId } from "@turbostarter/shared/utils";
|
||||
|
||||
import { uploadWithRetry } from "~/utils";
|
||||
|
||||
const MAX_FILE_SIZE_IN_MB = 5;
|
||||
const MAX_FILE_SIZE = MAX_FILE_SIZE_IN_MB * 1024 * 1024;
|
||||
const MAX_FILES_COUNT = 5;
|
||||
const ACCEPTED_FILE_TYPES = [
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/jpg",
|
||||
];
|
||||
|
||||
const useValidation = () => {
|
||||
const { t } = useTranslation(["validation"]);
|
||||
|
||||
const fileSchema = z
|
||||
.instanceof(File)
|
||||
.refine((file) => ACCEPTED_FILE_TYPES.includes(file.type), {
|
||||
message: t("error.file.type", {
|
||||
type: "image",
|
||||
}),
|
||||
})
|
||||
.refine((file) => file.size <= MAX_FILE_SIZE, {
|
||||
message: t("error.tooBig.file.notInclusive", {
|
||||
size: MAX_FILE_SIZE_IN_MB,
|
||||
}),
|
||||
});
|
||||
|
||||
const validate = (files: File[], attachments: File[]) => {
|
||||
const errors = new Set<string>();
|
||||
Array.from(files).forEach((file) => {
|
||||
try {
|
||||
fileSchema.parse(file);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError && error.issues[0]) {
|
||||
errors.add(error.issues[0].message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (files.length + attachments.length > MAX_FILES_COUNT) {
|
||||
errors.add(
|
||||
t("error.file.maxCount", {
|
||||
count: MAX_FILES_COUNT,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
files: files
|
||||
.filter((file) => fileSchema.safeParse(file).success)
|
||||
.slice(0, MAX_FILES_COUNT - attachments.length)
|
||||
.map((file) => new File([file], generateId(), { type: file.type })),
|
||||
};
|
||||
};
|
||||
|
||||
return { validate };
|
||||
};
|
||||
|
||||
interface AttachmentsState {
|
||||
attachments: File[];
|
||||
setAttachments: (attachments: File[]) => void;
|
||||
}
|
||||
|
||||
export const useAttachmentsStore = create<AttachmentsState>((set) => ({
|
||||
attachments: [],
|
||||
setAttachments: (attachments) => set({ attachments }),
|
||||
}));
|
||||
|
||||
export const useAttachments = () => {
|
||||
const { validate } = useValidation();
|
||||
const { attachments, setAttachments } = useAttachmentsStore();
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async ({ directory }: { directory: string }) => {
|
||||
setAttachments([]);
|
||||
await Promise.allSettled(
|
||||
attachments.map((attachment) =>
|
||||
uploadWithRetry({
|
||||
path: `${directory}/${attachment.name}.${
|
||||
attachment.type.split("/")[1] ?? "png"
|
||||
}`,
|
||||
file: attachment,
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
|
||||
const onAdd = useCallback(
|
||||
(files: File[]) => {
|
||||
const { errors, files: filesToAdd } = validate(files, attachments);
|
||||
|
||||
for (const error of errors) {
|
||||
toast.error(error);
|
||||
}
|
||||
|
||||
if (!filesToAdd.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAttachments([...attachments, ...filesToAdd]);
|
||||
},
|
||||
[attachments, setAttachments, validate],
|
||||
);
|
||||
|
||||
const onRemove = useCallback(
|
||||
(file: File) => {
|
||||
setAttachments(attachments.filter((a) => a.name !== file.name));
|
||||
},
|
||||
[attachments, setAttachments],
|
||||
);
|
||||
|
||||
const onPaste = useCallback(
|
||||
(event: React.ClipboardEvent) => {
|
||||
const items = event.clipboardData.items;
|
||||
|
||||
const files = Array.from(items)
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file !== null);
|
||||
|
||||
if (files.length > 0) {
|
||||
onAdd(files);
|
||||
}
|
||||
},
|
||||
[onAdd],
|
||||
);
|
||||
|
||||
const onClear = useCallback(() => {
|
||||
setAttachments([]);
|
||||
}, [setAttachments]);
|
||||
|
||||
return { attachments, upload, onAdd, onRemove, onPaste, onClear };
|
||||
};
|
||||
209
apps/web/src/modules/chat/composer/hooks/use-composer.tsx
Normal file
209
apps/web/src/modules/chat/composer/hooks/use-composer.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { Chat } from "@ai-sdk/react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useForm, useFormContext } from "react-hook-form";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
import { MODELS } from "@turbostarter/ai/chat/constants";
|
||||
import { chatMessageOptionsSchema } from "@turbostarter/ai/chat/schema";
|
||||
import { useDebounceCallback } from "@turbostarter/shared/hooks";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { api } from "~/lib/api/client";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { chat as chatApi } from "~/modules/chat/lib/api";
|
||||
import { useAIError } from "~/modules/common/hooks/use-ai-error";
|
||||
import { useCredits } from "~/modules/common/layout/credits";
|
||||
|
||||
import { useAttachments } from "./use-attachments";
|
||||
|
||||
import type { ChatMessageOptionsPayload } from "@turbostarter/ai/chat/schema";
|
||||
import type { ChatMessage } from "@turbostarter/ai/chat/types";
|
||||
import type { WatchObserver } from "react-hook-form";
|
||||
|
||||
interface ChatOptionsState {
|
||||
options: ChatMessageOptionsPayload;
|
||||
setOptions: (options: Partial<ChatMessageOptionsPayload>) => void;
|
||||
}
|
||||
|
||||
export const useChatOptions = create<ChatOptionsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
options: {
|
||||
reason: false,
|
||||
search: false,
|
||||
model: MODELS[0].id,
|
||||
},
|
||||
setOptions: (options) =>
|
||||
set((state) => ({
|
||||
options: {
|
||||
...state.options,
|
||||
...options,
|
||||
},
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: "chat-options",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const chats = new Map<string, Chat<ChatMessage>>();
|
||||
|
||||
const getChatInstance = ({
|
||||
id,
|
||||
...options
|
||||
}: ConstructorParameters<typeof Chat<ChatMessage>>[0]) => {
|
||||
if (!id || !chats.has(id)) {
|
||||
const chat = new Chat<ChatMessage>({
|
||||
id,
|
||||
...options,
|
||||
});
|
||||
|
||||
chats.set(id ?? chat.id, chat);
|
||||
}
|
||||
|
||||
const instance = chats.get(id ?? "");
|
||||
if (!instance) {
|
||||
throw new Error(`Chat instance with id ${id} not found!`);
|
||||
}
|
||||
return instance;
|
||||
};
|
||||
|
||||
interface UseComposerProps {
|
||||
readonly id?: string;
|
||||
readonly initialMessages?: ChatMessage[];
|
||||
}
|
||||
|
||||
export const useComposer = ({ id, initialMessages }: UseComposerProps = {}) => {
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const { onError } = useAIError();
|
||||
const { invalidate } = useCredits();
|
||||
const { data } = authClient.useSession();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { options, setOptions } = useChatOptions();
|
||||
const { attachments, upload, onClear } = useAttachments();
|
||||
const newForm = useForm({
|
||||
resolver: zodResolver(chatMessageOptionsSchema),
|
||||
defaultValues: options,
|
||||
});
|
||||
|
||||
const contextForm = useFormContext<ChatMessageOptionsPayload>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const form = contextForm ?? newForm;
|
||||
|
||||
const chat = getChatInstance({
|
||||
id,
|
||||
transport: new DefaultChatTransport({
|
||||
api: api.ai.chat.chats.$url().toString(),
|
||||
prepareSendMessagesRequest: ({ messages, id }) => {
|
||||
const lastMessage = messages.at(-1);
|
||||
|
||||
const directory = `attachments/${id}/${lastMessage?.id}`;
|
||||
|
||||
upload.mutate({
|
||||
directory,
|
||||
});
|
||||
|
||||
return {
|
||||
body: {
|
||||
...lastMessage,
|
||||
chatId: id,
|
||||
parts: lastMessage?.parts.map((part) =>
|
||||
part.type === "file"
|
||||
? {
|
||||
...part,
|
||||
path: `${directory}/${part.filename}.${part.mediaType.split("/")[1] ?? "png"}`,
|
||||
}
|
||||
: part,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
messages: initialMessages,
|
||||
onFinish: () => {
|
||||
void invalidate();
|
||||
if (!initialMessages?.length) {
|
||||
void queryClient.invalidateQueries(
|
||||
chatApi.queries.chats.user.getAll(data?.user.id ?? ""),
|
||||
);
|
||||
}
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
const { messages, sendMessage, ...rest } = useChat({
|
||||
chat,
|
||||
});
|
||||
|
||||
const syncOptions: WatchObserver<ChatMessageOptionsPayload> = useCallback(
|
||||
(values) => setOptions(values),
|
||||
[setOptions],
|
||||
);
|
||||
|
||||
const debouncedSyncOptions = useDebounceCallback(syncOptions, 500);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = form.watch(debouncedSyncOptions);
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, debouncedSyncOptions]);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(prompt?: string) => {
|
||||
const url = pathsConfig.apps.chat.chat(chat.id);
|
||||
|
||||
window.history.replaceState({}, "", url);
|
||||
|
||||
if (prompt) {
|
||||
return sendMessage({
|
||||
text: prompt,
|
||||
metadata: {
|
||||
options: chatMessageOptionsSchema.parse(form.getValues()),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const dataTransfer = new DataTransfer();
|
||||
attachments.forEach((attachment) => {
|
||||
dataTransfer.items.add(attachment);
|
||||
});
|
||||
|
||||
void sendMessage({
|
||||
text: input,
|
||||
files: dataTransfer.files,
|
||||
metadata: {
|
||||
options: chatMessageOptionsSchema.parse(form.getValues()),
|
||||
},
|
||||
});
|
||||
setInput("");
|
||||
}
|
||||
},
|
||||
[sendMessage, input, attachments, chat.id, form],
|
||||
);
|
||||
|
||||
const model = MODELS.find((model) => model.id === form.watch("model"));
|
||||
|
||||
useEffect(() => {
|
||||
if (!model?.attachments) {
|
||||
onClear();
|
||||
}
|
||||
}, [model?.attachments, onClear]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
form,
|
||||
onSubmit,
|
||||
input,
|
||||
setInput,
|
||||
model,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
261
apps/web/src/modules/chat/composer/hooks/use-voice-recording.tsx
Normal file
261
apps/web/src/modules/chat/composer/hooks/use-voice-recording.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
import type {
|
||||
UseVoiceRecordingOptions,
|
||||
UseVoiceRecordingReturn,
|
||||
VoiceRecordingState,
|
||||
} from "../types";
|
||||
|
||||
export const useVoiceRecording = (
|
||||
options: UseVoiceRecordingOptions = {}
|
||||
): UseVoiceRecordingReturn => {
|
||||
const { onTranscription, onError, onStateChange } = options;
|
||||
|
||||
const [state, setState] = useState<VoiceRecordingState>("idle");
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [audioLevel, setAudioLevel] = useState(0);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Update state and notify
|
||||
const updateState = useCallback(
|
||||
(newState: VoiceRecordingState) => {
|
||||
setState(newState);
|
||||
onStateChange?.(newState);
|
||||
},
|
||||
[onStateChange]
|
||||
);
|
||||
|
||||
// Cleanup function
|
||||
const cleanup = useCallback(() => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
void audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
analyserRef.current = null;
|
||||
mediaRecorderRef.current = null;
|
||||
chunksRef.current = [];
|
||||
setDuration(0);
|
||||
setAudioLevel(0);
|
||||
}, []);
|
||||
|
||||
// Monitor audio levels
|
||||
const monitorAudioLevel = useCallback(() => {
|
||||
if (!analyserRef.current) return;
|
||||
|
||||
const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount);
|
||||
analyserRef.current.getByteFrequencyData(dataArray);
|
||||
|
||||
// Calculate average volume (0-100)
|
||||
const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
|
||||
const normalizedLevel = Math.min(100, Math.round((average / 255) * 100 * 2));
|
||||
setAudioLevel(normalizedLevel);
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(monitorAudioLevel);
|
||||
}, []);
|
||||
|
||||
// Start recording
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
cleanup();
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
|
||||
// Setup audio analysis
|
||||
const audioContext = new AudioContext();
|
||||
audioContextRef.current = audioContext;
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
analyserRef.current = analyser;
|
||||
|
||||
// Start audio level monitoring
|
||||
monitorAudioLevel();
|
||||
|
||||
// Setup media recorder
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: MediaRecorder.isTypeSupported("audio/webm")
|
||||
? "audio/webm"
|
||||
: "audio/mp4",
|
||||
});
|
||||
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
chunksRef.current = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
// Stop level monitoring
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
|
||||
console.log("[Voice] Recording stopped, chunks:", chunksRef.current.length);
|
||||
|
||||
if (chunksRef.current.length === 0) {
|
||||
console.log("[Voice] No chunks recorded, aborting");
|
||||
cleanup();
|
||||
updateState("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
updateState("processing");
|
||||
|
||||
try {
|
||||
const audioBlob = new Blob(chunksRef.current, {
|
||||
type: mediaRecorder.mimeType,
|
||||
});
|
||||
|
||||
console.log("[Voice] Audio blob:", audioBlob.size, "bytes,", mediaRecorder.mimeType);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
"audio",
|
||||
audioBlob,
|
||||
`recording.${mediaRecorder.mimeType.includes("webm") ? "webm" : "mp4"}`
|
||||
);
|
||||
|
||||
const url = api.ai.stt.$url().toString();
|
||||
console.log("[Voice] Sending to:", url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
console.log("[Voice] Response status:", response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
console.error("[Voice] Error response:", errorData);
|
||||
throw new Error(
|
||||
(errorData as { message?: string }).message ??
|
||||
"Transcription failed"
|
||||
);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as { text: string };
|
||||
console.log("[Voice] Transcription result:", result.text);
|
||||
onTranscription?.(result.text);
|
||||
} catch (err) {
|
||||
console.error("[Voice] Error:", err);
|
||||
const transcriptionError =
|
||||
err instanceof Error ? err : new Error("Transcription failed");
|
||||
setError(transcriptionError);
|
||||
onError?.(transcriptionError);
|
||||
} finally {
|
||||
cleanup();
|
||||
updateState("idle");
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
updateState("recording");
|
||||
|
||||
// Start duration timer
|
||||
setDuration(0);
|
||||
timerRef.current = setInterval(() => {
|
||||
setDuration((prev) => prev + 1);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
const accessError =
|
||||
err instanceof Error
|
||||
? err
|
||||
: new Error("Failed to access microphone");
|
||||
setError(accessError);
|
||||
onError?.(accessError);
|
||||
cleanup();
|
||||
updateState("idle");
|
||||
}
|
||||
}, [cleanup, monitorAudioLevel, onTranscription, onError, updateState]);
|
||||
|
||||
// Stop recording (will trigger transcription)
|
||||
const stopRecording = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (mediaRecorderRef.current?.state === "recording") {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cancel recording (no transcription)
|
||||
const cancelRecording = useCallback(() => {
|
||||
cleanup();
|
||||
updateState("idle");
|
||||
}, [cleanup, updateState]);
|
||||
|
||||
// Toggle recording
|
||||
const toggleRecording = useCallback(() => {
|
||||
if (state === "recording") {
|
||||
stopRecording();
|
||||
} else if (state === "idle") {
|
||||
void startRecording();
|
||||
}
|
||||
}, [state, startRecording, stopRecording]);
|
||||
|
||||
// Escape key handler
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && state === "recording") {
|
||||
e.preventDefault();
|
||||
cancelRecording();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [state, cancelRecording]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}, [cleanup]);
|
||||
|
||||
return {
|
||||
state,
|
||||
duration,
|
||||
audioLevel,
|
||||
error,
|
||||
isRecording: state === "recording",
|
||||
isProcessing: state === "processing",
|
||||
startRecording,
|
||||
stopRecording,
|
||||
cancelRecording,
|
||||
toggleRecording,
|
||||
};
|
||||
};
|
||||
185
apps/web/src/modules/chat/composer/index.tsx
Normal file
185
apps/web/src/modules/chat/composer/index.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { MODELS } from "@turbostarter/ai/chat/constants";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Toggle } from "@turbostarter/ui-web/toggle";
|
||||
|
||||
import { Composer } from "~/modules/common/ai/composer";
|
||||
import { ModelSelector } from "~/modules/common/ai/composer/model-selector";
|
||||
|
||||
import { VoiceButton } from "./components/voice-button";
|
||||
import { useAttachments } from "./hooks/use-attachments";
|
||||
import { useComposer } from "./hooks/use-composer";
|
||||
import { useVoiceRecording } from "./hooks/use-voice-recording";
|
||||
|
||||
import type { ChatMessage } from "@turbostarter/ai/chat/types";
|
||||
|
||||
interface ChatComposerProps {
|
||||
readonly id?: string;
|
||||
readonly initialMessages?: ChatMessage[];
|
||||
}
|
||||
|
||||
export const ChatComposer = ({
|
||||
id,
|
||||
initialMessages,
|
||||
}: ChatComposerProps = {}) => {
|
||||
const { t } = useTranslation(["ai", "common"]);
|
||||
const { status, stop, form, onSubmit, model, input, setInput } = useComposer({
|
||||
id,
|
||||
initialMessages,
|
||||
});
|
||||
|
||||
const { attachments, onRemove, onPaste } = useAttachments();
|
||||
|
||||
const {
|
||||
state: voiceState,
|
||||
duration,
|
||||
audioLevel,
|
||||
toggleRecording,
|
||||
cancelRecording,
|
||||
} = useVoiceRecording({
|
||||
onTranscription: (text) => {
|
||||
setInput((prev) => (prev ? `${prev} ${text}` : text));
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error.message.includes("microphone")
|
||||
? t("microphoneDenied", { ns: "common" })
|
||||
: t("transcriptionFailed", { ns: "common" });
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const isSubmitting = ["submitted", "streaming"].includes(status);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<Composer.Form onSubmit={form.handleSubmit(() => onSubmit())}>
|
||||
<Composer.Input className="pb-12">
|
||||
<Composer.Attachments.Preview
|
||||
attachments={attachments}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
|
||||
<Composer.Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.currentTarget.value)}
|
||||
maxLength={5_000}
|
||||
placeholder={t("chat.composer.placeholder")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!isSubmitting) {
|
||||
return form.handleSubmit(() => onSubmit())();
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPaste={onPaste}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 flex w-full gap-1.5 overflow-hidden border-2 border-transparent p-2 @[480px]/input:p-3">
|
||||
<Composer.Attachments.Input disabled={!model?.attachments} />
|
||||
|
||||
<div className="flex max-w-full grow gap-1.5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="search"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Toggle
|
||||
variant="outline"
|
||||
className="text-muted-foreground w-9 gap-1.5 rounded-full p-0 @lg:w-auto @lg:px-3.5"
|
||||
pressed={model?.tools && !!field.value}
|
||||
onPressedChange={field.onChange}
|
||||
disabled={!model?.tools}
|
||||
>
|
||||
<Icons.Globe className="size-4 shrink-0" />
|
||||
<span className="text-foreground hidden @lg:inline">
|
||||
{t("search.label")}
|
||||
</span>
|
||||
</Toggle>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="reason"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Toggle
|
||||
variant="outline"
|
||||
className="text-muted-foreground w-9 gap-1.5 rounded-full p-0 @lg:w-auto @lg:px-3.5"
|
||||
pressed={model?.reason && !!field.value}
|
||||
onPressedChange={field.onChange}
|
||||
disabled={!model?.reason}
|
||||
>
|
||||
<Icons.Sparkle className="size-4" />
|
||||
<span className="text-foreground hidden @lg:inline">
|
||||
{t("reason")}
|
||||
</span>
|
||||
</Toggle>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModelSelector
|
||||
control={form.control}
|
||||
name="model"
|
||||
options={MODELS}
|
||||
/>
|
||||
|
||||
<VoiceButton
|
||||
state={voiceState}
|
||||
duration={duration}
|
||||
audioLevel={audioLevel}
|
||||
disabled={isSubmitting}
|
||||
onToggle={toggleRecording}
|
||||
onCancel={cancelRecording}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="shrink-0 rounded-full"
|
||||
disabled={!input.trim() && !isSubmitting}
|
||||
size="icon"
|
||||
type="submit"
|
||||
onClick={(e) => {
|
||||
if (isSubmitting) {
|
||||
e.preventDefault();
|
||||
return stop();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Icons.Square className="size-4 fill-current" />
|
||||
<span className="sr-only">{t("stop")}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icons.ArrowUp className="size-5" />
|
||||
<span className="sr-only">{t("send")}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Composer.Input>
|
||||
</Composer.Form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
38
apps/web/src/modules/chat/composer/types.ts
Normal file
38
apps/web/src/modules/chat/composer/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Voice Recording Types
|
||||
|
||||
export type VoiceRecordingState = "idle" | "recording" | "processing";
|
||||
|
||||
export interface VoiceRecordingData {
|
||||
state: VoiceRecordingState;
|
||||
duration: number; // seconds elapsed
|
||||
audioLevel: number; // 0-100 volume level
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export interface UseVoiceRecordingOptions {
|
||||
onTranscription?: (text: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onStateChange?: (state: VoiceRecordingState) => void;
|
||||
}
|
||||
|
||||
export interface UseVoiceRecordingReturn {
|
||||
state: VoiceRecordingState;
|
||||
duration: number;
|
||||
audioLevel: number;
|
||||
error: Error | null;
|
||||
isRecording: boolean;
|
||||
isProcessing: boolean;
|
||||
startRecording: () => Promise<void>;
|
||||
stopRecording: () => void;
|
||||
cancelRecording: () => void;
|
||||
toggleRecording: () => void;
|
||||
}
|
||||
|
||||
export interface VoiceButtonProps {
|
||||
state: VoiceRecordingState;
|
||||
duration: number;
|
||||
audioLevel: number;
|
||||
disabled?: boolean;
|
||||
onToggle: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
25
apps/web/src/modules/chat/history/actions.tsx
Normal file
25
apps/web/src/modules/chat/history/actions.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { CommandGroup, CommandItem } from "@turbostarter/ui-web/command";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
interface ChatActionsProps {
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
export const ChatActions = ({ onSelect }: ChatActionsProps) => {
|
||||
const { t } = useTranslation(["common", "ai"]);
|
||||
|
||||
return (
|
||||
<CommandGroup heading={t("actions")}>
|
||||
<CommandItem asChild>
|
||||
<TurboLink href={pathsConfig.apps.chat.index} onClick={onSelect}>
|
||||
<Icons.SquarePen />
|
||||
<span>{t("chat.new")}</span>
|
||||
</TurboLink>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
);
|
||||
};
|
||||
97
apps/web/src/modules/chat/history/index.tsx
Normal file
97
apps/web/src/modules/chat/history/index.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
} from "@turbostarter/ui-web/command";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import { ChatActions } from "./actions";
|
||||
import { ChatHistoryList } from "./list";
|
||||
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface CommandMenuProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const CommandMenu = ({ open, onOpenChange }: CommandMenuProps) => {
|
||||
const { t } = useTranslation("ai");
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<CommandInput placeholder={t("chat.command.search")} />
|
||||
<CommandList className="h-[420px]">
|
||||
<CommandEmpty className="py-10">{t("chat.command.empty")}</CommandEmpty>
|
||||
<ChatActions onSelect={() => onOpenChange(false)} />
|
||||
<ChatHistoryList onSelect={() => onOpenChange(false)} />
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const ChatHistory = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setIsOpen((open) => !open);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", down);
|
||||
return () => document.removeEventListener("keydown", down);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group relative"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<Icons.TextSearch className="text-muted-foreground group-hover:text-foreground size-5" />
|
||||
<span className="sr-only">{t("history")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<span>{t("history")}</span>
|
||||
<kbd className="text-muted-foreground pointer-events-none inline-flex items-center gap-0.5 pl-1 font-mono select-none">
|
||||
{/* eslint-disable-next-line i18next/no-literal-string */}
|
||||
<span className="">⌘</span>K
|
||||
</kbd>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<CommandMenu open={isOpen} onOpenChange={setIsOpen} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
57
apps/web/src/modules/chat/history/list/index.tsx
Normal file
57
apps/web/src/modules/chat/history/list/index.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { useDateGroups } from "@turbostarter/shared/hooks";
|
||||
import { CommandGroup } from "@turbostarter/ui-web/command";
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
|
||||
import { chat } from "../../lib/api";
|
||||
|
||||
import { ChatHistoryListItem } from "./item";
|
||||
|
||||
interface ChatHistoryListProps {
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
export const ChatHistoryList = ({ onSelect }: ChatHistoryListProps) => {
|
||||
const { t } = useTranslation("common");
|
||||
const { data: session } = authClient.useSession();
|
||||
const userChats = useQuery(
|
||||
chat.queries.chats.user.getAll(session?.user.id ?? ""),
|
||||
);
|
||||
|
||||
const groups = useDateGroups(userChats.data ?? []);
|
||||
|
||||
if (userChats.isLoading) {
|
||||
return (
|
||||
<CommandGroup heading={t("history")} className="w-full">
|
||||
<Skeleton className="mb-2 h-11 w-3/4 rounded-xl" />
|
||||
<Skeleton className="mb-2 h-11 w-full rounded-xl" />
|
||||
<Skeleton className="h-11 w-1/2 rounded-xl" />
|
||||
</CommandGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups.map(
|
||||
(group) =>
|
||||
group.items.length > 0 && (
|
||||
<CommandGroup heading={group.label} key={group.label}>
|
||||
{group.items.map((chat) => (
|
||||
<ChatHistoryListItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</CommandGroup>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
167
apps/web/src/modules/chat/history/list/item.tsx
Normal file
167
apps/web/src/modules/chat/history/list/item.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import dayjs from "dayjs";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { CommandItem } from "@turbostarter/ui-web/command";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
import { chat as chatApi } from "../../lib/api";
|
||||
|
||||
import type { Chat } from "@turbostarter/ai/chat/types";
|
||||
|
||||
interface ChatHistoryListItemProps {
|
||||
readonly chat: Chat;
|
||||
readonly onSelect: () => void;
|
||||
}
|
||||
|
||||
export const ChatHistoryListItem = ({
|
||||
chat,
|
||||
onSelect,
|
||||
}: ChatHistoryListItemProps) => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={chat.id}
|
||||
value={`${chat.id}-${chat.name}`}
|
||||
asChild
|
||||
onSelect={() => {
|
||||
router.push(pathsConfig.apps.chat.chat(chat.id));
|
||||
onSelect();
|
||||
}}
|
||||
className="group"
|
||||
>
|
||||
<div>
|
||||
<TurboLink
|
||||
href={pathsConfig.apps.chat.chat(chat.id)}
|
||||
onClick={onSelect}
|
||||
className="flex min-w-0 grow items-center justify-start gap-3"
|
||||
>
|
||||
<Icons.MessagesSquare />
|
||||
<span className="min-w-0 truncate">{chat.name}</span>
|
||||
{pathname.includes(chat.id) && (
|
||||
<Badge variant="outline">{t("current")}</Badge>
|
||||
)}
|
||||
</TurboLink>
|
||||
<Controls chat={chat} />
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
|
||||
const Controls = ({ chat }: { chat: Chat }) => {
|
||||
const { data: session } = authClient.useSession();
|
||||
const userId = session?.user.id ?? "";
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { mutate } = useMutation({
|
||||
...chatApi.mutations.chats.delete,
|
||||
onMutate: async (data) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: chatApi.queries.chats.user.getAll(userId).queryKey,
|
||||
});
|
||||
|
||||
const previousChats = queryClient.getQueryData(
|
||||
chatApi.queries.chats.user.getAll(userId).queryKey,
|
||||
);
|
||||
|
||||
queryClient.setQueryData(
|
||||
chatApi.queries.chats.user.getAll(userId).queryKey,
|
||||
(old: Chat[]) => old.filter((chat) => chat.id !== data.id),
|
||||
);
|
||||
|
||||
if (pathname.includes(chat.id)) {
|
||||
router.push(pathsConfig.apps.chat.index);
|
||||
}
|
||||
|
||||
return { previousChats };
|
||||
},
|
||||
onError: (error, _, context) => {
|
||||
toast.error(error.message);
|
||||
queryClient.setQueryData(
|
||||
chatApi.queries.chats.user.getAll(userId).queryKey,
|
||||
context?.previousChats,
|
||||
);
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries(
|
||||
chatApi.queries.chats.user.getAll(userId),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="text-muted-foreground ml-auto whitespace-nowrap group-data-[selected=true]:hidden">
|
||||
{dayjs(chat.createdAt).fromNow()}
|
||||
</span>
|
||||
|
||||
<div className="-my-2 ml-auto hidden items-center gap-2 group-data-[selected=true]:flex">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-muted-foreground/10 size-7 rounded-lg"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(pathsConfig.apps.chat.chat(chat.id), "_blank");
|
||||
}}
|
||||
>
|
||||
<Icons.ExternalLink className="text-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("newTab")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-muted-foreground/10 size-7 rounded-lg"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
mutate({ id: chat.id });
|
||||
}}
|
||||
>
|
||||
<Icons.Trash className="text-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("delete")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
78
apps/web/src/modules/chat/layout/examples.tsx
Normal file
78
apps/web/src/modules/chat/layout/examples.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { memo } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { useComposer } from "~/modules/chat/composer/hooks/use-composer";
|
||||
|
||||
const examples = [
|
||||
{
|
||||
icon: Icons.FileText,
|
||||
label: "chat.example.summarize.label",
|
||||
prompt: "chat.example.summarize.prompt",
|
||||
},
|
||||
{
|
||||
icon: Icons.ChartNoAxesColumn,
|
||||
label: "chat.example.analyze.label",
|
||||
prompt: "chat.example.analyze.prompt",
|
||||
},
|
||||
{
|
||||
icon: Icons.Code,
|
||||
label: "chat.example.code.label",
|
||||
prompt: "chat.example.code.prompt",
|
||||
},
|
||||
{
|
||||
icon: Icons.Zap,
|
||||
label: "chat.example.brainstorm.label",
|
||||
prompt: "chat.example.brainstorm.prompt",
|
||||
},
|
||||
{
|
||||
icon: Icons.PackageOpen,
|
||||
label: "chat.example.surprise.label",
|
||||
prompt: "chat.example.surprise.prompt",
|
||||
},
|
||||
] as const;
|
||||
|
||||
interface ExamplesProps {
|
||||
readonly id?: string;
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
export const Examples = memo<ExamplesProps>(({ className, id }) => {
|
||||
const { t } = useTranslation("ai");
|
||||
const { onSubmit } = useComposer({ id });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-row flex-wrap items-center justify-center gap-2 px-3 @sm:gap-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{examples.map(({ icon: Icon, label, prompt }, index) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||||
initial={{ opacity: 0, y: 3, filter: "blur(4px)" }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
key={label}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-muted-foreground gap-2 rounded-full"
|
||||
onClick={() => onSubmit(t(prompt))}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{t(label)}</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Examples.displayName = "Examples";
|
||||
14
apps/web/src/modules/chat/layout/headline.tsx
Normal file
14
apps/web/src/modules/chat/layout/headline.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { getGreeting } from "@turbostarter/shared/utils";
|
||||
|
||||
export const Headline = () => {
|
||||
const { t } = useTranslation(["common", "ai"]);
|
||||
const { text, emoji } = getGreeting();
|
||||
|
||||
return (
|
||||
<h1 className="leading-tighter flex w-full flex-col items-center justify-center text-center text-2xl tracking-tight @sm:text-3xl @md:text-4xl">
|
||||
{t(`greeting.${text}`)} {emoji}
|
||||
<span className="text-muted-foreground">{t("ai:chat.headline")}</span>
|
||||
</h1>
|
||||
);
|
||||
};
|
||||
32
apps/web/src/modules/chat/layout/new.tsx
Normal file
32
apps/web/src/modules/chat/layout/new.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { memo } from "react";
|
||||
|
||||
import { ChatComposer } from "~/modules/chat/composer";
|
||||
import { ChatDropzone } from "~/modules/chat/composer/dropzone";
|
||||
import { useComposer } from "~/modules/chat/composer/hooks/use-composer";
|
||||
import { Examples } from "~/modules/chat/layout/examples";
|
||||
import { Headline } from "~/modules/chat/layout/headline";
|
||||
|
||||
interface NewChatProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const NewChat = memo<NewChatProps>(({ id }) => {
|
||||
const { model } = useComposer({ id });
|
||||
return (
|
||||
<ChatDropzone disabled={!model?.attachments}>
|
||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-between gap-6 md:justify-center md:gap-9 md:p-2">
|
||||
<div className="flex w-full grow items-end">
|
||||
<Headline />
|
||||
</div>
|
||||
<div className="flex w-full grow flex-col items-center justify-between md:flex-col-reverse md:justify-end md:gap-5">
|
||||
<Examples className="flex" id={id} />
|
||||
<div className="relative w-full px-3 pb-3">
|
||||
<ChatComposer id={id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ChatDropzone>
|
||||
);
|
||||
});
|
||||
|
||||
NewChat.displayName = "NewChat";
|
||||
34
apps/web/src/modules/chat/layout/view.tsx
Normal file
34
apps/web/src/modules/chat/layout/view.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
import { ChatComposer } from "~/modules/chat/composer";
|
||||
import { ChatDropzone } from "~/modules/chat/composer/dropzone";
|
||||
import { Chat } from "~/modules/chat/thread";
|
||||
|
||||
import { useComposer } from "../composer/hooks/use-composer";
|
||||
|
||||
import type { ChatMessage } from "@turbostarter/ai/chat/types";
|
||||
|
||||
interface ViewChatProps {
|
||||
readonly id: string;
|
||||
readonly initialMessages?: ChatMessage[];
|
||||
}
|
||||
|
||||
export const ViewChat = memo<ViewChatProps>(({ id, initialMessages }) => {
|
||||
const { model } = useComposer({ id, initialMessages });
|
||||
|
||||
return (
|
||||
<ChatDropzone disabled={!model?.attachments}>
|
||||
<Chat id={id} initialMessages={initialMessages} />
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 z-50 mx-auto max-w-[50rem]">
|
||||
<div className="relative z-40 flex w-full flex-col items-center px-3 pb-3">
|
||||
<ChatComposer id={id} initialMessages={initialMessages} />
|
||||
</div>
|
||||
</div>
|
||||
</ChatDropzone>
|
||||
);
|
||||
});
|
||||
|
||||
ViewChat.displayName = "ViewChat";
|
||||
38
apps/web/src/modules/chat/lib/api.ts
Normal file
38
apps/web/src/modules/chat/lib/api.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as z from "zod";
|
||||
|
||||
import { chatSchema } from "@turbostarter/ai/chat/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
const KEY = "chat";
|
||||
|
||||
const queries = {
|
||||
chats: {
|
||||
user: {
|
||||
getAll: (userId: string) => ({
|
||||
queryKey: [KEY, "chats", userId],
|
||||
queryFn: handle(api.ai.chat.chats.$get, {
|
||||
schema: z.array(chatSchema),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
chats: {
|
||||
delete: {
|
||||
mutationKey: [KEY, "chats", "delete"],
|
||||
mutationFn: ({ id }: { id: string }) =>
|
||||
handle(api.ai.chat.chats[":id"].$delete)({
|
||||
param: { id },
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const chat = {
|
||||
queries,
|
||||
mutations,
|
||||
} as const;
|
||||
39
apps/web/src/modules/chat/thread/index.tsx
Normal file
39
apps/web/src/modules/chat/thread/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { Role } from "@turbostarter/ai/chat/types";
|
||||
|
||||
import { Thread } from "../../common/ai/thread";
|
||||
import { useComposer } from "../composer/hooks/use-composer";
|
||||
|
||||
import { AssistantMessage } from "./message/assistant";
|
||||
import { UserMessage } from "./message/user";
|
||||
|
||||
import type { ChatMessage } from "@turbostarter/ai/chat/types";
|
||||
|
||||
interface ChatProps {
|
||||
readonly id?: string;
|
||||
readonly initialMessages?: ChatMessage[];
|
||||
}
|
||||
|
||||
const components = {
|
||||
[Role.USER]: UserMessage,
|
||||
[Role.ASSISTANT]: AssistantMessage,
|
||||
};
|
||||
|
||||
export const Chat = ({ id, initialMessages }: ChatProps = {}) => {
|
||||
const { messages, regenerate, error, status } = useComposer({
|
||||
id,
|
||||
initialMessages,
|
||||
});
|
||||
|
||||
return (
|
||||
<Thread
|
||||
messages={messages}
|
||||
initialMessages={initialMessages}
|
||||
status={status}
|
||||
components={components}
|
||||
error={error}
|
||||
regenerate={regenerate}
|
||||
/>
|
||||
);
|
||||
};
|
||||
67
apps/web/src/modules/chat/thread/message/assistant/index.tsx
Normal file
67
apps/web/src/modules/chat/thread/message/assistant/index.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { memo } from "react";
|
||||
|
||||
import { WebSearch } from "~/modules/chat/thread/message/assistant/tools/web-search";
|
||||
import { ThreadMessage } from "~/modules/common/ai/thread/message";
|
||||
import { MemoizedMarkdown } from "~/modules/common/markdown/memoized-markdown";
|
||||
import { Prose } from "~/modules/common/prose";
|
||||
|
||||
import { ReasoningMessagePart } from "./reasoning";
|
||||
|
||||
import type { ChatMessage } from "@turbostarter/ai/chat/types";
|
||||
import type { ThreadMessageProps } from "~/modules/common/ai/thread/message";
|
||||
|
||||
export const AssistantMessage = memo<ThreadMessageProps<ChatMessage>>(
|
||||
({ message, ref, status }) => {
|
||||
return (
|
||||
<ThreadMessage.Layout className="items-start" ref={ref}>
|
||||
<Prose className="w-full max-w-none">
|
||||
{message.parts.map((part, partIndex) => {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return (
|
||||
<MemoizedMarkdown
|
||||
key={`${message.id}-${partIndex}`}
|
||||
content={part.text}
|
||||
id={`text-${partIndex}`}
|
||||
/>
|
||||
);
|
||||
case "reasoning":
|
||||
return (
|
||||
<ReasoningMessagePart
|
||||
key={`${message.id}-${partIndex}`}
|
||||
part={part}
|
||||
reasoning={
|
||||
status === "streaming" &&
|
||||
partIndex === message.parts.length - 1
|
||||
}
|
||||
defaultExpanded={status === "streaming"}
|
||||
/>
|
||||
);
|
||||
|
||||
case "tool-web-search":
|
||||
switch (part.state) {
|
||||
case "input-available":
|
||||
case "output-available":
|
||||
return (
|
||||
<WebSearch
|
||||
key={`${message.id}-${partIndex}`}
|
||||
{...part}
|
||||
annotations={message.parts.filter(
|
||||
(p) => p.type === "data-query_completion",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
})}
|
||||
</Prose>
|
||||
|
||||
{!["submitted", "streaming"].includes(status) && (
|
||||
<ThreadMessage.Controls message={message} />
|
||||
)}
|
||||
</ThreadMessage.Layout>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AssistantMessage.displayName = "AssistantMessage";
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@turbostarter/ui-web/accordion";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { MemoizedMarkdown } from "~/modules/common/markdown/memoized-markdown";
|
||||
|
||||
import type { ReasoningUIPart } from "ai";
|
||||
|
||||
interface ReasoningMessagePartProps {
|
||||
part: ReasoningUIPart;
|
||||
reasoning: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
export function ReasoningMessagePart({
|
||||
part,
|
||||
reasoning,
|
||||
defaultExpanded = false,
|
||||
}: ReasoningMessagePartProps) {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
if (!part.text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
defaultValue={defaultExpanded ? "reasoning" : undefined}
|
||||
className="w-full"
|
||||
>
|
||||
<AccordionItem value="reasoning" className="border-none [&_h3]:my-0">
|
||||
<AccordionTrigger
|
||||
className={cn(
|
||||
"not-prose border-border bg-background rounded-xl border p-3 pr-4 shadow-xs hover:no-underline",
|
||||
"data-[state=open]:rounded-b-none",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-muted rounded-lg p-1 md:p-1.5">
|
||||
{reasoning ? (
|
||||
<Icons.Loader className="text-muted-foreground size-3.5 animate-spin md:size-4" />
|
||||
) : (
|
||||
<Icons.Sparkle className="text-muted-foreground size-3.5 md:size-4" />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-left font-medium">
|
||||
{reasoning
|
||||
? t("reasoning.inProgress")
|
||||
: t("reasoning.completed")}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="mt-0 border-0 py-0">
|
||||
<div className="rounded-b-xl border border-t-0 px-5 py-3 shadow-xs">
|
||||
<div className="text-muted-foreground prose-p:my-1.5 text-sm">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<MemoizedMarkdown id={part.type} content={part.text} />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { useBreakpoint } from "@turbostarter/ui-web";
|
||||
|
||||
import { Thumbnail, ThumbnailImage, Viewer } from "~/modules/common/image";
|
||||
|
||||
import type { SearchResult } from ".";
|
||||
|
||||
type SearchImage = SearchResult["images"][number];
|
||||
|
||||
export const PREVIEW_IMAGE_COUNT = {
|
||||
MOBILE: 4,
|
||||
DESKTOP: 5,
|
||||
};
|
||||
|
||||
interface ImageGridProps {
|
||||
images: SearchImage[];
|
||||
showAll?: boolean;
|
||||
}
|
||||
|
||||
const ImageThumbnail = ({
|
||||
image,
|
||||
index,
|
||||
onClick,
|
||||
isLast,
|
||||
hasMore,
|
||||
moreCount,
|
||||
}: {
|
||||
image: SearchImage;
|
||||
index: number;
|
||||
onClick: () => void;
|
||||
isLast: boolean;
|
||||
hasMore: boolean;
|
||||
moreCount: number;
|
||||
}) => (
|
||||
<Thumbnail onClick={onClick} index={index}>
|
||||
<ThumbnailImage src={image.url} alt={image.description} />
|
||||
{image.description && (!isLast || !hasMore) && (
|
||||
<div className="absolute inset-0 flex items-end bg-black/60 px-3 py-4 opacity-0 transition-opacity duration-200 group-hover/thumbnail:opacity-100">
|
||||
<p className="line-clamp-3 text-xs text-white">{image.description}</p>
|
||||
</div>
|
||||
)}
|
||||
{isLast && hasMore && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
|
||||
<span className="text-sm font-medium text-white">+{moreCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</Thumbnail>
|
||||
);
|
||||
|
||||
export const ImageGrid = ({ images, showAll = false }: ImageGridProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
const isDesktop = useBreakpoint("md");
|
||||
|
||||
const displayImages = showAll
|
||||
? images
|
||||
: images.slice(
|
||||
0,
|
||||
isDesktop ? PREVIEW_IMAGE_COUNT.DESKTOP : PREVIEW_IMAGE_COUNT.MOBILE,
|
||||
);
|
||||
const hasMore =
|
||||
images.length >
|
||||
(isDesktop ? PREVIEW_IMAGE_COUNT.DESKTOP : PREVIEW_IMAGE_COUNT.MOBILE);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2",
|
||||
"grid-cols-2",
|
||||
displayImages.length === 1 && "grid-cols-1",
|
||||
"sm:grid-cols-3",
|
||||
"lg:grid-cols-4",
|
||||
"*:aspect-4/3",
|
||||
"[&>*:first-child]:col-span-1 [&>*:first-child]:row-span-1",
|
||||
isDesktop &&
|
||||
displayImages.length > 1 &&
|
||||
"[&>*:first-child]:col-span-2 [&>*:first-child]:row-span-2",
|
||||
displayImages.length === 1 &&
|
||||
"grid-cols-1! [&>*:first-child]:col-span-1! [&>*:first-child]:row-span-2!",
|
||||
)}
|
||||
>
|
||||
{displayImages.map((image, index) => (
|
||||
<ImageThumbnail
|
||||
key={index}
|
||||
image={image}
|
||||
index={index}
|
||||
onClick={() => {
|
||||
setSelectedImage(index);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
isLast={index === displayImages.length - 1}
|
||||
hasMore={!showAll && hasMore}
|
||||
moreCount={images.length - displayImages.length}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Viewer
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
images={images}
|
||||
selectedImage={selectedImage}
|
||||
setSelectedImage={setSelectedImage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@turbostarter/ui-web/accordion";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { ImageGrid } from "./images";
|
||||
import { SearchLoading } from "./loading";
|
||||
|
||||
import type {
|
||||
ChatDataParts,
|
||||
ChatTools,
|
||||
Tool,
|
||||
} from "@turbostarter/ai/chat/types";
|
||||
import type { DataUIPart } from "ai";
|
||||
|
||||
const ResultCard = ({
|
||||
result,
|
||||
}: {
|
||||
result: SearchResult["results"][number];
|
||||
}) => {
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="border-border bg-background h-full w-[300px] shrink-0 rounded-xl border shadow-xs">
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2.5">
|
||||
<div className="bg-muted relative flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg">
|
||||
{!imageLoaded && (
|
||||
<div className="bg-muted-foreground/10 absolute inset-0 animate-pulse" />
|
||||
)}
|
||||
<img
|
||||
src={`https://www.google.com/s2/favicons?sz=128&domain=${new URL(result.url).hostname}`}
|
||||
alt=""
|
||||
className={cn("size-8 object-cover", !imageLoaded && "opacity-0")}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
onError={(e) => {
|
||||
setImageLoaded(true);
|
||||
e.currentTarget.src =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cline x1='12' y1='8' x2='12' y2='16'/%3E%3Cline x1='8' y1='12' x2='16' y2='12'/%3E%3C/svg%3E";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="line-clamp-1 text-sm font-medium">{result.title}</h3>
|
||||
<a
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs"
|
||||
>
|
||||
{new URL(result.url).hostname}
|
||||
<Icons.ExternalLink className="size-2.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground line-clamp-3 text-sm">
|
||||
{result.content}
|
||||
</p>
|
||||
|
||||
{result.publishedDate && (
|
||||
<div className="pt-2">
|
||||
<time className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<Icons.Calendar className="size-3" />
|
||||
{new Date(result.publishedDate).toLocaleDateString()}
|
||||
</time>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type SearchResult = NonNullable<
|
||||
ChatTools[typeof Tool.WEB_SEARCH]["output"]
|
||||
>["searches"][number];
|
||||
|
||||
export const WebSearch = (
|
||||
props: Partial<ChatTools[typeof Tool.WEB_SEARCH]> & {
|
||||
annotations: DataUIPart<ChatDataParts>[];
|
||||
},
|
||||
) => {
|
||||
const { input, output, annotations } = props;
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
if (!output) {
|
||||
return (
|
||||
<SearchLoading queries={input?.queries ?? []} annotations={annotations} />
|
||||
);
|
||||
}
|
||||
|
||||
const allImages = output.searches.reduce<SearchResult["images"]>(
|
||||
(acc, search) => {
|
||||
return [...acc, ...search.images];
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const totalResults = output.searches.reduce(
|
||||
(sum, search) => sum + search.results.length,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="not-prose w-full space-y-4 pb-2">
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
defaultValue="search"
|
||||
className="w-full"
|
||||
>
|
||||
<AccordionItem value="search" className="border-none [&_h3]:my-0">
|
||||
<AccordionTrigger
|
||||
className={cn(
|
||||
"border-border bg-background rounded-xl border p-3 pr-4 shadow-xs hover:no-underline",
|
||||
"[&[data-state=open]]:rounded-b-none",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-muted rounded-lg p-1.5">
|
||||
<Icons.Globe className="text-muted-foreground size-4" />
|
||||
</div>
|
||||
<h2 className="text-left font-medium">
|
||||
{t("search.completed")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mr-2 flex items-center gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-muted rounded-full px-3 py-1"
|
||||
>
|
||||
<Icons.Search className="mr-1.5 size-3" />
|
||||
{totalResults} {t("results")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="mt-0 border-0 py-0">
|
||||
<div className="border-border bg-background rounded-b-xl border border-t-0 px-4 py-3 shadow-xs">
|
||||
<div className="no-scrollbar mb-3 flex gap-2 overflow-x-auto pb-1">
|
||||
{output.searches.map((search, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
variant="secondary"
|
||||
className="bg-muted flex-shrink-0 rounded-full px-3 py-1.5"
|
||||
>
|
||||
<Icons.Search className="mr-1.5 size-3" />
|
||||
{search.query.q}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="no-scrollbar flex gap-3 overflow-x-auto">
|
||||
{output.searches.map((search) =>
|
||||
search.results.map((result, resultIndex) => (
|
||||
<motion.div
|
||||
key={`${search.query.q}-${resultIndex}`}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3, delay: resultIndex * 0.1 }}
|
||||
>
|
||||
<ResultCard result={result} />
|
||||
</motion.div>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{allImages.length > 0 && <ImageGrid images={allImages} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { useBreakpoint } from "@turbostarter/ui-web";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@turbostarter/ui-web/accordion";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
|
||||
import { PREVIEW_IMAGE_COUNT } from "./images";
|
||||
|
||||
import type {
|
||||
ChatTools,
|
||||
ChatDataParts,
|
||||
Tool,
|
||||
} from "@turbostarter/ai/chat/types";
|
||||
import type { DataUIPart } from "ai";
|
||||
|
||||
export const SearchLoading = ({
|
||||
queries,
|
||||
annotations,
|
||||
}: {
|
||||
queries: ChatTools[typeof Tool.WEB_SEARCH]["input"]["queries"];
|
||||
annotations: DataUIPart<ChatDataParts>[];
|
||||
}) => {
|
||||
const isDesktop = useBreakpoint("md");
|
||||
const { t } = useTranslation("common");
|
||||
const totalResults = annotations.reduce(
|
||||
(sum, a) => sum + a.data.resultsCount,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="not-prose w-full space-y-4 pb-2">
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
defaultValue="search"
|
||||
className="w-full"
|
||||
>
|
||||
<AccordionItem value="search" className="border-none [&_h3]:my-0">
|
||||
<AccordionTrigger
|
||||
className={cn(
|
||||
"border-border bg-background rounded-xl border p-3 shadow-xs hover:no-underline",
|
||||
"data-[state=open]:rounded-b-none",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-muted rounded-lg p-1.5">
|
||||
<Icons.Loader className="text-muted-foreground size-4 animate-spin" />
|
||||
</div>
|
||||
<h2 className="text-left font-medium">
|
||||
{t("search.inProgress")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mr-2 flex items-center gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-muted rounded-full px-3 py-1"
|
||||
>
|
||||
<Icons.Search className="mr-1.5 size-3" />
|
||||
{totalResults || "0"} {t("results")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="mt-0 border-0 py-0">
|
||||
<div className="border-border bg-background rounded-b-xl border border-t-0 px-4 py-3 shadow-xs">
|
||||
<div className="no-scrollbar mb-3 flex gap-2 overflow-x-auto pb-1">
|
||||
{queries.map((query, i) => {
|
||||
const annotation = annotations.find(
|
||||
(a) =>
|
||||
a.data.query.q === query.q &&
|
||||
a.data.status === "completed",
|
||||
);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={i}
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"shrink-0 gap-1.5 rounded-full px-3 py-1.5",
|
||||
!annotation && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{annotation ? (
|
||||
<Icons.Check className="size-3" />
|
||||
) : (
|
||||
<Icons.Loader2 className="size-3 animate-spin stroke-[3px]" />
|
||||
)}
|
||||
{query.q}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="no-scrollbar flex gap-3 overflow-x-auto pb-1">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-border bg-background w-[300px] shrink-0 rounded-xl border shadow-xs"
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2.5">
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-5/6" />
|
||||
<Skeleton className="h-3 w-4/6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({
|
||||
length: isDesktop
|
||||
? PREVIEW_IMAGE_COUNT.DESKTOP
|
||||
: PREVIEW_IMAGE_COUNT.MOBILE,
|
||||
}).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className={cn(
|
||||
"aspect-4/3 rounded-xl",
|
||||
i === 0 && "sm:col-span-2 sm:row-span-2",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
85
apps/web/src/modules/chat/thread/message/user/index.tsx
Normal file
85
apps/web/src/modules/chat/thread/message/user/index.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { memo, useState } from "react";
|
||||
|
||||
import { ThreadMessage } from "~/modules/common/ai/thread/message";
|
||||
import { Thumbnail, ThumbnailImage, Viewer } from "~/modules/common/image";
|
||||
import { Prose } from "~/modules/common/prose";
|
||||
|
||||
import type { ChatMessage } from "@turbostarter/ai/chat/types";
|
||||
import type { FileUIPart } from "ai";
|
||||
import type { ThreadMessageProps } from "~/modules/common/ai/thread/message";
|
||||
|
||||
const Attachments = ({ attachments }: { attachments: FileUIPart[] }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
|
||||
if (!attachments.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-1 flex max-w-full flex-row flex-wrap items-center justify-end gap-1.5">
|
||||
{attachments
|
||||
.filter((attachment) => attachment.mediaType.includes("image/"))
|
||||
.map((attachment, index) => {
|
||||
return (
|
||||
<Thumbnail
|
||||
key={attachment.url}
|
||||
index={index}
|
||||
onClick={() => {
|
||||
setIsOpen(true);
|
||||
setSelectedImage(index);
|
||||
}}
|
||||
className="aspect-square h-24 w-24 border bg-transparent shadow-none sm:h-32 sm:w-32 dark:bg-transparent"
|
||||
>
|
||||
<ThumbnailImage
|
||||
src={attachment.url}
|
||||
alt=""
|
||||
key={attachment.url}
|
||||
/>
|
||||
</Thumbnail>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Viewer
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
images={attachments}
|
||||
selectedImage={selectedImage}
|
||||
setSelectedImage={setSelectedImage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserMessage = memo<ThreadMessageProps<ChatMessage>>(
|
||||
({ message, ref }) => {
|
||||
const attachments = message.parts.filter((part) => part.type === "file");
|
||||
return (
|
||||
<ThreadMessage.Layout className="items-end" ref={ref}>
|
||||
{attachments.length > 0 && (
|
||||
<Attachments
|
||||
key={`${message.id}-attachments`}
|
||||
attachments={attachments}
|
||||
/>
|
||||
)}
|
||||
{message.parts.map((part, index) => {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return (
|
||||
<Prose
|
||||
key={`${message.id}-${index}`}
|
||||
className="bg-muted min-h-7 max-w-full rounded-3xl rounded-br-lg border px-4 py-2.5 sm:max-w-[90%]"
|
||||
>
|
||||
{part.text}
|
||||
</Prose>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ThreadMessage.Layout>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
UserMessage.displayName = "UserMessage";
|
||||
227
apps/web/src/modules/common/ai/composer/attachments.tsx
Normal file
227
apps/web/src/modules/common/ai/composer/attachments.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
import { createContext, memo, useContext, useMemo } from "react";
|
||||
import { useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import { Viewer } from "~/modules/common/image";
|
||||
|
||||
import type { DropzoneOptions, DropzoneState } from "react-dropzone";
|
||||
|
||||
const DropzoneContext = createContext<{
|
||||
dropzone: DropzoneState;
|
||||
} | null>(null);
|
||||
|
||||
interface DropzoneProps extends DropzoneOptions {
|
||||
children: React.ReactNode;
|
||||
dialog?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Dropzone = ({ children, dialog, ...options }: DropzoneProps) => {
|
||||
const dropzone = useDropzone({
|
||||
accept: {
|
||||
"image/*": [".png", ".gif", ".jpeg", ".webp", ".jpg"],
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
noClick: true,
|
||||
noKeyboard: true,
|
||||
multiple: true,
|
||||
...options,
|
||||
});
|
||||
|
||||
return (
|
||||
<DropzoneContext.Provider value={{ dropzone }}>
|
||||
<div {...dropzone.getRootProps()} className="relative h-full w-full">
|
||||
{children}
|
||||
|
||||
<AnimatePresence>
|
||||
{dropzone.isDragActive && dialog && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center">
|
||||
<motion.div
|
||||
className="bg-background/50 absolute inset-0 backdrop-blur-sm md:rounded-lg"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
/>
|
||||
|
||||
{dialog}
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</DropzoneContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const Input = memo<React.ButtonHTMLAttributes<HTMLButtonElement>>((props) => {
|
||||
const { t } = useTranslation(["ai", "common"]);
|
||||
const context = useContext(DropzoneContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
{...context?.dropzone.getInputProps()}
|
||||
disabled={props.disabled ?? false}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
{...props}
|
||||
className={cn(
|
||||
"text-muted-foreground shrink-0 rounded-full dark:bg-transparent",
|
||||
props.className,
|
||||
)}
|
||||
onClick={(event) => {
|
||||
context?.dropzone.open();
|
||||
props.onClick?.(event);
|
||||
}}
|
||||
>
|
||||
<Icons.Paperclip className="size-4" />
|
||||
<span className="sr-only">{t("chat.composer.files.add")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<span>{t("chat.composer.files.add")}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
interface PreviewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
attachments: File[];
|
||||
onRemove: (file: File) => void;
|
||||
}
|
||||
|
||||
export const Preview = memo<PreviewProps>(
|
||||
({ attachments, onRemove, className, ...props }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
|
||||
if (!attachments.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"-mb-2.5 flex w-full flex-wrap gap-3 px-2 pt-4 @[480px]/input:px-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{attachments.map((attachment, index) => (
|
||||
<Thumbnail
|
||||
key={attachment.name}
|
||||
attachment={attachment}
|
||||
onRemove={() => onRemove(attachment)}
|
||||
onClick={() => {
|
||||
setSelectedImage(index);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Viewer
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
images={attachments.map((attachment) => ({
|
||||
url: URL.createObjectURL(attachment),
|
||||
}))}
|
||||
selectedImage={selectedImage}
|
||||
setSelectedImage={setSelectedImage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Preview.displayName = "Preview";
|
||||
|
||||
interface ThumbnailProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
attachment: File;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const Thumbnail = memo<ThumbnailProps>(({ attachment, onRemove, ...props }) => {
|
||||
const { t } = useTranslation(["ai"]);
|
||||
const preview = useMemo(() => URL.createObjectURL(attachment), [attachment]);
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
<button {...props} type="button">
|
||||
<Avatar className="size-16 shrink-0 rounded-xl">
|
||||
<AvatarImage
|
||||
src={preview}
|
||||
alt={`Preview of ${attachment.name}`}
|
||||
className="rounded-xl border object-cover"
|
||||
/>
|
||||
<AvatarFallback className="rounded-xl">
|
||||
<Icons.Image className="text-muted-foreground size-8" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<span className="sr-only">{t("chat.composer.files.preview")}</span>
|
||||
</button>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="bg-card dark:bg-card absolute top-0 right-0 size-5 translate-x-1/3 -translate-y-1/3 p-1"
|
||||
onClick={onRemove}
|
||||
type="button"
|
||||
>
|
||||
<Icons.X className="size-full" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
className="rounded-md px-2 py-1 text-xs"
|
||||
>
|
||||
<span>{t("chat.composer.files.remove")}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Thumbnail.displayName = "Thumbnail";
|
||||
|
||||
export const Attachments = {
|
||||
Input,
|
||||
Dropzone,
|
||||
Preview,
|
||||
};
|
||||
90
apps/web/src/modules/common/ai/composer/index.tsx
Normal file
90
apps/web/src/modules/common/ai/composer/index.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { TextareaAutosize } from "@turbostarter/ui-web/textarea";
|
||||
|
||||
import { Attachments } from "./attachments";
|
||||
|
||||
const Form = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLFormElement>) => {
|
||||
const ref = useRef<HTMLFormElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
ref.current
|
||||
?.closest("main")
|
||||
?.style.setProperty(
|
||||
"--composer-height",
|
||||
`${entry.contentRect.height}px`,
|
||||
);
|
||||
});
|
||||
|
||||
if (ref.current) {
|
||||
resizeObserver.observe(ref.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative bottom-0 z-10 flex w-full flex-col items-center justify-center gap-2 text-base",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const Input = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card/65 ring-border/75 focus-within:ring-input hover:ring-input hover:focus-within:ring-input @container/input relative w-full max-w-200 rounded-2xl px-2 pb-2 ring-1 backdrop-blur-xl duration-100 ring-inset focus-within:ring-1 @lg:rounded-3xl @lg:shadow-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Textarea = ({
|
||||
className,
|
||||
...props
|
||||
}: Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "style">) => {
|
||||
return (
|
||||
<TextareaAutosize
|
||||
dir="auto"
|
||||
className={cn(
|
||||
"text-foreground mb-3 min-h-20 w-full resize-none bg-transparent px-2 pt-5 align-bottom focus:outline-none @[480px]/input:px-3",
|
||||
className,
|
||||
)}
|
||||
spellCheck={false}
|
||||
maxRows={6}
|
||||
autoFocus
|
||||
maxLength={5_000}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Composer = {
|
||||
Form,
|
||||
Input,
|
||||
Textarea,
|
||||
Attachments,
|
||||
};
|
||||
68
apps/web/src/modules/common/ai/composer/model-selector.tsx
Normal file
68
apps/web/src/modules/common/ai/composer/model-selector.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { FormControl, FormField, FormItem } from "@turbostarter/ui-web/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectPortal,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@turbostarter/ui-web/select";
|
||||
|
||||
import { ProviderIcons } from "~/modules/common/ai/icons";
|
||||
|
||||
import type { Provider } from "@turbostarter/ai";
|
||||
import type { Control, FieldValues, Path } from "react-hook-form";
|
||||
|
||||
interface ModelSelectorProps<T extends FieldValues> {
|
||||
readonly control: Control<T>;
|
||||
readonly name: Path<T>;
|
||||
readonly options: readonly {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly provider: Provider;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const ModelSelector = <T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
options,
|
||||
}: ModelSelectorProps<T>) => {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-0">
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
<SelectContent align="end">
|
||||
{options.map((option) => {
|
||||
const Icon = ProviderIcons[option.provider];
|
||||
|
||||
return (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Icon className="text-foreground size-4 shrink-0" />
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
{option.name}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
16
apps/web/src/modules/common/ai/icons.tsx
Normal file
16
apps/web/src/modules/common/ai/icons.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Provider } from "@turbostarter/ai";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
export const ProviderIcons = {
|
||||
[Provider.OPENAI]: Icons.OpenAI,
|
||||
[Provider.GEMINI]: Icons.Gemini,
|
||||
[Provider.CLAUDE]: Icons.Claude,
|
||||
[Provider.GROK]: Icons.Grok,
|
||||
[Provider.DEEPSEEK]: Icons.DeepSeek,
|
||||
[Provider.REPLICATE]: Icons.Replicate,
|
||||
[Provider.LUMA]: Icons.Luma,
|
||||
[Provider.STABILITY_AI]: Icons.StabilityAI,
|
||||
[Provider.RECRAFT]: Icons.Recraft,
|
||||
[Provider.ELEVEN_LABS]: Icons.ElevenLabs,
|
||||
[Provider.NVIDIA]: Icons.Nvidia,
|
||||
};
|
||||
149
apps/web/src/modules/common/ai/thread/analyzing-image.tsx
Normal file
149
apps/web/src/modules/common/ai/thread/analyzing-image.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { TextShimmer } from "@turbostarter/ui-web/text-shimmer";
|
||||
|
||||
import type { Transition } from "motion/react";
|
||||
|
||||
const transition: Transition = {
|
||||
duration: 2.5,
|
||||
ease: [0.175, 0.885, 0.32, 1],
|
||||
times: [0, 0.6, 0.6, 1],
|
||||
repeat: Infinity,
|
||||
repeatType: "mirror",
|
||||
repeatDelay: 0.2,
|
||||
};
|
||||
|
||||
export const AnalyzingImage = () => {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="relative isolate flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{
|
||||
clipPath: "inset(0px 0px 0px 0px)",
|
||||
}}
|
||||
animate={{
|
||||
clipPath: [
|
||||
"inset(0px 0px 0px 0px)",
|
||||
"inset(0px 24px 0px 0px)",
|
||||
"inset(0px 24px 0px 0px)",
|
||||
"inset(0px 0px 0px 0px)",
|
||||
],
|
||||
}}
|
||||
transition={transition}
|
||||
className="bg-background z-10"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-muted-foreground/65"
|
||||
>
|
||||
<rect width="20" height="20" fill="hsl(var(--background))" />
|
||||
<path
|
||||
d="M4.27209 20.7279L10.8686 14.1314C11.2646 13.7354 11.4627 13.5373 11.691 13.4632C11.8918 13.3979 12.1082 13.3979 12.309 13.4632C12.5373 13.5373 12.7354 13.7354 13.1314 14.1314L19.6839 20.6839M14 15L16.8686 12.1314C17.2646 11.7354 17.4627 11.5373 17.691 11.4632C17.8918 11.3979 18.1082 11.3979 18.309 11.4632C18.5373 11.5373 18.7354 11.7354 19.1314 12.1314L22 15M10 9C10 10.1046 9.10457 11 8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9ZM6.8 21H17.2C18.8802 21 19.7202 21 20.362 20.673C20.9265 20.3854 21.3854 19.9265 21.673 19.362C22 18.7202 22 17.8802 22 16.2V7.8C22 6.11984 22 5.27976 21.673 4.63803C21.3854 4.07354 20.9265 3.6146 20.362 3.32698C19.7202 3 18.8802 3 17.2 3H6.8C5.11984 3 4.27976 3 3.63803 3.32698C3.07354 3.6146 2.6146 4.07354 2.32698 4.63803C2 5.27976 2 6.11984 2 7.8V16.2C2 17.8802 2 18.7202 2.32698 19.362C2.6146 19.9265 3.07354 20.3854 3.63803 20.673C4.27976 21 5.11984 21 6.8 21Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ transform: "translateX(10px)" }}
|
||||
animate={{
|
||||
transform: [
|
||||
"translateX(10px)",
|
||||
"translateX(-10px)",
|
||||
"translateX(-10px)",
|
||||
"translateX(10px)",
|
||||
],
|
||||
}}
|
||||
transition={transition}
|
||||
className="bg-muted-foreground/65 absolute z-10 h-full w-[3px] rounded-full"
|
||||
/>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-muted-foreground/65 absolute"
|
||||
>
|
||||
<rect width="20" height="20" fill="hsl(var(--background))" />
|
||||
<path
|
||||
d="M6.8 21H17.2C18.8802 21 19.7202 21 20.362 20.673C20.9265 20.3854 21.3854 19.9265 21.673 19.362C22 18.7202 22 17.8802 22 16.2V7.8C22 6.11984 22 5.27976 21.673 4.63803C21.3854 4.07354 20.9265 3.6146 20.362 3.32698C19.7202 3 18.8802 3 17.2 3H6.8C5.11984 3 4.27976 3 3.63803 3.32698C3.07354 3.6146 2.6146 4.07354 2.32698 4.63803C2 5.27976 2 6.11984 2 7.8V16.2C2 17.8802 2 18.7202 2.32698 19.362C2.6146 19.9265 3.07354 20.3854 3.63803 20.673C4.27976 21 5.11984 21 6.8 21Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<rect x="6" y="19" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="18" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="19" width="3" height="1" fill="currentColor" />
|
||||
<rect x="9" y="18" width="1" height="1" fill="currentColor" />
|
||||
<rect x="14" y="19" width="3" height="1" fill="currentColor" />
|
||||
<rect x="15" y="18" width="1" height="1" fill="currentColor" />
|
||||
<rect x="5" y="18" width="2" height="1" fill="currentColor" />
|
||||
<rect x="5" y="17" width="1" height="1" fill="currentColor" />
|
||||
<rect x="10" y="19" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="17" width="1" height="1" fill="currentColor" />
|
||||
<rect x="11" y="19" width="1" height="1" fill="currentColor" />
|
||||
<rect x="10" y="18" width="1" height="1" fill="currentColor" />
|
||||
<rect x="17" y="19" width="1" height="1" fill="currentColor" />
|
||||
<rect x="15" y="4" width="2" height="1" fill="currentColor" />
|
||||
<rect x="3" y="9" width="1" height="3" fill="currentColor" />
|
||||
<rect x="4" y="10" width="1" height="2" fill="currentColor" />
|
||||
<rect x="6" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="15" y="5" width="1" height="1" fill="currentColor" />
|
||||
<rect x="20" y="8" width="1" height="3" fill="currentColor" />
|
||||
<rect x="19" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="13" width="1" height="1" fill="currentColor" />
|
||||
<rect x="9" y="11" width="1" height="1" fill="currentColor" />
|
||||
<rect x="16" y="12" width="1" height="2" fill="currentColor" />
|
||||
<rect x="13" y="14" width="1" height="1" fill="currentColor" />
|
||||
<rect x="12" y="11" width="1" height="1" fill="currentColor" />
|
||||
<rect x="10" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="10" y="15" width="1" height="1" fill="currentColor" />
|
||||
<rect x="10" y="13" width="1" height="1" fill="currentColor" />
|
||||
<rect x="15" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="13" y="10" width="1" height="1" fill="currentColor" />
|
||||
<rect x="12" y="14" width="1" height="1" fill="currentColor" />
|
||||
<rect x="5" y="4" width="3" height="1" fill="currentColor" />
|
||||
<rect x="6" y="5" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="14" width="1" height="2" fill="currentColor" />
|
||||
<rect x="6" y="14" width="3" height="1" fill="currentColor" />
|
||||
<rect x="16" y="8" width="1" height="1" fill="currentColor" />
|
||||
<rect x="8" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="20" y="16" width="1" height="1" fill="currentColor" />
|
||||
<rect x="12" y="12" width="1" height="1" fill="currentColor" />
|
||||
<rect x="8" y="8" width="1" height="1" fill="currentColor" />
|
||||
<rect x="14" y="12" width="1" height="1" fill="currentColor" />
|
||||
<rect x="17" y="16" width="2" height="1" fill="currentColor" />
|
||||
<rect x="14" y="17" width="1" height="1" fill="currentColor" />
|
||||
<rect x="11" y="5" width="3" height="1" fill="currentColor" />
|
||||
<rect x="12" y="4" width="1" height="1" fill="currentColor" />
|
||||
<rect x="12" y="7" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="11" width="1" height="1" fill="currentColor" />
|
||||
<rect x="15" y="15" width="1" height="1" fill="currentColor" />
|
||||
<rect x="11" y="11" width="1" height="1" fill="currentColor" />
|
||||
<rect x="13" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="12" y="15" width="1" height="1" fill="currentColor" />
|
||||
<rect x="9" y="12" width="2" height="1" fill="currentColor" />
|
||||
<rect x="19" y="13" width="2" height="1" fill="currentColor" />
|
||||
<rect x="9" y="6" width="1" height="1" fill="currentColor" />
|
||||
<rect x="20" y="4" width="1" height="1" fill="currentColor" />
|
||||
<rect x="19" y="4" width="1" height="1" fill="currentColor" />
|
||||
<rect x="3" y="15" width="1" height="2" fill="currentColor" />
|
||||
<rect x="3" y="19" width="1" height="1" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<TextShimmer className="text-sm font-medium" duration={1.5}>
|
||||
{t("analyzingImage")}
|
||||
</TextShimmer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
77
apps/web/src/modules/common/ai/thread/controls/copy.tsx
Normal file
77
apps/web/src/modules/common/ai/thread/controls/copy.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
import { getMessageTextContent } from "@turbostarter/ai";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import { useCopy } from "~/modules/common/hooks/use-copy";
|
||||
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
const transition = {
|
||||
initial: { opacity: 0, scale: 0.8 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0.8 },
|
||||
transition: { duration: 0.1, ease: "easeInOut" as const },
|
||||
};
|
||||
|
||||
interface ThreadMessageCopyProps<MESSAGE extends UIMessage = UIMessage> {
|
||||
message: MESSAGE;
|
||||
}
|
||||
|
||||
export const ThreadMessageCopy = <MESSAGE extends UIMessage = UIMessage>({
|
||||
message,
|
||||
}: ThreadMessageCopyProps<MESSAGE>) => {
|
||||
const { t } = useTranslation("common");
|
||||
const { copied, copy } = useCopy();
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group/button size-8 rounded-full"
|
||||
onClick={() => copy(getMessageTextContent(message))}
|
||||
>
|
||||
<div className="relative size-3.5">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{copied ? (
|
||||
<motion.div
|
||||
key="check"
|
||||
{...transition}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<Icons.Check className="text-muted-foreground group-hover/button:text-foreground size-3.5" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="copy"
|
||||
{...transition}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<Icons.Copy className="text-muted-foreground group-hover/button:text-foreground size-3.5" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<span className="sr-only">{t("copy")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t("copy")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
25
apps/web/src/modules/common/ai/thread/controls/index.tsx
Normal file
25
apps/web/src/modules/common/ai/thread/controls/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { ThreadMessageCopy } from "./copy";
|
||||
import { ThreadMessageLikes } from "./likes";
|
||||
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
interface ControlsProps {
|
||||
message: UIMessage;
|
||||
}
|
||||
|
||||
export const Controls = ({ message }: ControlsProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background start-0 -ml-4 flex w-max items-center gap-px rounded-lg px-2 pb-2 text-xs opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 md:start-3",
|
||||
)}
|
||||
>
|
||||
{message.parts.some(
|
||||
(part) => part.type === "text" && part.text.length > 0,
|
||||
) && <ThreadMessageCopy message={message} />}
|
||||
<ThreadMessageLikes />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
71
apps/web/src/modules/common/ai/thread/controls/likes.tsx
Normal file
71
apps/web/src/modules/common/ai/thread/controls/likes.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
export const ThreadMessageLikes = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const [likeState, setLikeState] = useState<-1 | 0 | 1>(0);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group/button size-8 rounded-full"
|
||||
onClick={() => setLikeState(likeState === 1 ? 0 : 1)}
|
||||
>
|
||||
<Icons.ThumbsUp
|
||||
className={cn(
|
||||
"size-3.5 transition-colors",
|
||||
likeState === 1
|
||||
? "text-primary fill-current"
|
||||
: "text-muted-foreground group-hover/button:text-foreground",
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">{t("like")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t("like")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group/button size-8 rounded-full"
|
||||
onClick={() => setLikeState(likeState === -1 ? 0 : -1)}
|
||||
>
|
||||
<Icons.ThumbsDown
|
||||
className={cn(
|
||||
"size-3.5 transition-colors",
|
||||
likeState === -1
|
||||
? "text-primary fill-current"
|
||||
: "text-muted-foreground group-hover/button:text-foreground",
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">{t("dislike")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t("dislike")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Role } from "@turbostarter/ai/chat/types";
|
||||
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
interface UseThreadLayoutProps<MESSAGE extends UIMessage> {
|
||||
readonly messages: MESSAGE[];
|
||||
readonly initialMessages?: MESSAGE[];
|
||||
}
|
||||
|
||||
export const useThreadLayout = <MESSAGE extends UIMessage>({
|
||||
messages,
|
||||
initialMessages,
|
||||
}: UseThreadLayoutProps<MESSAGE>) => {
|
||||
const [scrolledByUser, setScrolledByUser] = useState(false);
|
||||
|
||||
const lastMessage = messages.at(-1);
|
||||
const lastMessageRef = useRef<HTMLDivElement>(null);
|
||||
const isChatActive = initialMessages?.length !== messages.length;
|
||||
|
||||
const lastUserMessageIndex = [...messages]
|
||||
.reverse()
|
||||
.findIndex((m) => m.role === Role.USER);
|
||||
const lastResponseMessages = messages.slice(
|
||||
lastUserMessageIndex !== 0 ? -2 : -1,
|
||||
);
|
||||
const previousMessages = messages.slice(0, -lastResponseMessages.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastMessageRef.current) return;
|
||||
|
||||
const parent = lastMessageRef.current.parentElement;
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrolledByUser(true);
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
setScrolledByUser(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
parent?.addEventListener("scroll", handleScroll);
|
||||
|
||||
return () => {
|
||||
parent?.removeEventListener("scroll", handleScroll);
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [lastMessageRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastMessageRef.current) return;
|
||||
|
||||
const parent = lastMessageRef.current.parentElement;
|
||||
|
||||
const isAtBottom = () => {
|
||||
const container = parent?.closest("[data-radix-scroll-area-viewport]");
|
||||
|
||||
if (!container) return false;
|
||||
|
||||
const scrollBottom = container.scrollTop + container.clientHeight;
|
||||
return Math.abs(container.scrollHeight - scrollBottom) < 150;
|
||||
};
|
||||
|
||||
if (isChatActive) {
|
||||
if (lastMessage?.role === Role.USER) {
|
||||
requestAnimationFrame(() => {
|
||||
parent?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "end",
|
||||
});
|
||||
});
|
||||
} else if (isAtBottom() && !scrolledByUser) {
|
||||
requestAnimationFrame(() => {
|
||||
parent?.scrollIntoView({
|
||||
behavior: "instant",
|
||||
block: "end",
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const animationFrameId = requestAnimationFrame(() => {
|
||||
parent?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "end",
|
||||
});
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(animationFrameId);
|
||||
}, [lastMessage, scrolledByUser, isChatActive]);
|
||||
|
||||
return {
|
||||
lastMessage,
|
||||
lastMessageRef,
|
||||
isChatActive,
|
||||
lastResponseMessages,
|
||||
previousMessages,
|
||||
};
|
||||
};
|
||||
134
apps/web/src/modules/common/ai/thread/index.tsx
Normal file
134
apps/web/src/modules/common/ai/thread/index.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import { Role } from "@turbostarter/ai/chat/types";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { ScrollArea } from "@turbostarter/ui-web/scroll-area";
|
||||
|
||||
import { AnalyzingImage } from "./analyzing-image";
|
||||
import { useThreadLayout } from "./hooks/use-thread-layout";
|
||||
import { ThreadMessage } from "./message";
|
||||
|
||||
import type { ThreadMessageComponents } from "./message";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
interface ThreadProps<MESSAGE extends UIMessage> {
|
||||
readonly messages: MESSAGE[];
|
||||
readonly initialMessages?: MESSAGE[];
|
||||
readonly status: string;
|
||||
readonly error?: Error | null;
|
||||
readonly regenerate?: () => Promise<void>;
|
||||
readonly className?: string;
|
||||
readonly components: ThreadMessageComponents<MESSAGE>;
|
||||
readonly footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Thread = <MESSAGE extends UIMessage>({
|
||||
messages,
|
||||
initialMessages,
|
||||
status,
|
||||
error,
|
||||
regenerate,
|
||||
className,
|
||||
components,
|
||||
footer,
|
||||
}: ThreadProps<MESSAGE>) => {
|
||||
const { t } = useTranslation("common");
|
||||
const isReloading = useRef(false);
|
||||
|
||||
const {
|
||||
lastMessage,
|
||||
lastMessageRef,
|
||||
isChatActive,
|
||||
previousMessages,
|
||||
lastResponseMessages,
|
||||
} = useThreadLayout({ messages, initialMessages });
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
messages.at(-1)?.role === Role.USER &&
|
||||
status === "ready" &&
|
||||
!isReloading.current
|
||||
) {
|
||||
isReloading.current = true;
|
||||
void regenerate?.().finally(() => {
|
||||
isReloading.current = false;
|
||||
});
|
||||
}
|
||||
}, [regenerate, messages, status]);
|
||||
|
||||
const renderMessage = useCallback(
|
||||
(message: MESSAGE) => {
|
||||
return (
|
||||
<ThreadMessage.Message
|
||||
message={message}
|
||||
key={message.id}
|
||||
status={status}
|
||||
components={components}
|
||||
{...(message.id === lastMessage?.id && { ref: lastMessageRef })}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[lastMessage?.id, lastMessageRef, status, components],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"@container/thread h-full w-full pt-12 pb-4 md:pt-14",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="px-5">
|
||||
{previousMessages.map(renderMessage)}
|
||||
<div
|
||||
className={cn("mx-auto flex w-full max-w-3xl flex-col", {
|
||||
"min-h-[calc(100vh-4rem)] md:min-h-[calc(100vh-5.5rem)]":
|
||||
isChatActive,
|
||||
})}
|
||||
>
|
||||
{lastResponseMessages.map(renderMessage)}
|
||||
{["submitted", "streaming"].includes(status) && (
|
||||
<div className="relative py-4 md:px-4">
|
||||
{status === "submitted" &&
|
||||
messages.at(-1)?.role === Role.USER &&
|
||||
messages
|
||||
.at(-1)
|
||||
?.parts.some(
|
||||
(part) =>
|
||||
part.type === "file" && part.mediaType.startsWith("image"),
|
||||
) ? (
|
||||
<AnalyzingImage />
|
||||
) : (
|
||||
<Icons.Loader className="text-muted-foreground size-5 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{footer}
|
||||
{error && (
|
||||
<div className="relative pb-4 @lg/thread:px-2 @xl/thread:px-4">
|
||||
<div className="bg-destructive/10 dark:bg-destructive/40 flex w-fit flex-wrap items-center gap-3 rounded-xl p-5 py-3">
|
||||
<p className="text-destructive dark:text-foreground">
|
||||
{t("error.general")}
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="h-auto gap-2"
|
||||
onClick={() => regenerate?.()}
|
||||
>
|
||||
<Icons.RotateCw className="size-4" />
|
||||
{t("reload")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full pb-[calc(var(--composer-height)+20px)]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
66
apps/web/src/modules/common/ai/thread/message.tsx
Normal file
66
apps/web/src/modules/common/ai/thread/message.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { Controls } from "./controls";
|
||||
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
export type ThreadMessageComponents<MESSAGE extends UIMessage> = Record<
|
||||
string,
|
||||
React.ComponentType<ThreadMessageProps<MESSAGE>>
|
||||
>;
|
||||
|
||||
export interface ThreadMessageProps<T extends UIMessage = UIMessage> {
|
||||
readonly status: string;
|
||||
readonly message: T;
|
||||
readonly ref?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
const Message = <MESSAGE extends UIMessage>(
|
||||
props: ThreadMessageProps<MESSAGE> & {
|
||||
components: ThreadMessageComponents<MESSAGE>;
|
||||
},
|
||||
) => {
|
||||
const role = props.message.role;
|
||||
|
||||
const isSupportedRole = (
|
||||
role: string,
|
||||
): role is keyof typeof props.components => {
|
||||
return role in props.components;
|
||||
};
|
||||
|
||||
if (!isSupportedRole(role)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Component = props.components[role];
|
||||
|
||||
if (!Component) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Component {...props} />;
|
||||
};
|
||||
|
||||
const Layout = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative mx-auto flex w-full max-w-3xl scroll-mb-[calc(var(--composer-height,140px)+36px)] flex-col justify-center gap-1 py-4 @md/thread:px-1 @lg/thread:px-2 @xl/thread:px-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ThreadMessage = {
|
||||
Layout,
|
||||
Message,
|
||||
Controls,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user