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:
Alejandro Gutiérrez
2026-04-04 21:19:32 +01:00
commit d3163a5bff
1384 changed files with 314925 additions and 0 deletions

View File

@@ -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>
);
}

View 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,
},
];
};

View File

@@ -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>
);
};

View File

@@ -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);

View 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>
);
};

View 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}
/>
);

View 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>
);
});

View 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;

View 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,
},
];
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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,
},
];
};

View File

@@ -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>
);
};

View File

@@ -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,
},
];
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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,
},
];
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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,
},
];
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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,
},
];
};

View File

@@ -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>
);
};

View File

@@ -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,
},
];
};

View File

@@ -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>
);
};

View File

@@ -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,
},
];
};

View File

@@ -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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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];

View 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>
);
};

View 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";

View 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>
);
};

View 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";

View 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>
);
};

View 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";

View 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>
);
};

View 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";

View 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 }),
}));

View 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 };

View 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 };

View 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 };

View 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>
);
};

View 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";

View 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>
);
};

View 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,
};

View 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} />
</>
);
});

View 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);

View 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,
};

View 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",
},
],
};

View 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";

View 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";

View File

@@ -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,
};
};

View 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">
&nbsp;{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";

View 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";

View 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>
);
};

View 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";

View 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;

View 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";

View 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 };
};

View 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,
};
};

View 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,
};
};

View 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>
);
};

View 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;
}

View 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>
);
};

View 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} />
</>
);
};

View 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>
),
)}
</>
);
};

View 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>
</>
);
};

View 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";

View 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>
);
};

View 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";

View 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";

View 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;

View 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}
/>
);
};

View 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";

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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";

View 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,
};

View 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,
};

View 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>
)}
/>
);
};

View 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,
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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,
};
};

View 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>
);
};

View 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