Production-ready Next.js boilerplate with: - Runtime env validation (fail-fast on missing vars) - Feature-gated config (S3, Stripe, email, OAuth) - Docker + Coolify deployment pipeline - PostgreSQL + pgvector, MinIO S3, Better Auth - TypeScript strict mode (no ignoreBuildErrors) - i18n (en/es), AI modules, billing, monitoring Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
291 lines
8.6 KiB
TypeScript
291 lines
8.6 KiB
TypeScript
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,
|
|
},
|
|
];
|
|
};
|