TorbatYar/frontend/modules/loyalty/features/membership/customer-profile.tsx
Mortezakoohjani d579d0b142 feat(loyalty): add Loyalty Platform Frontend module
Add frontend/modules/loyalty with types, API client, design system, feature pages and thin App Router routes under app/loyalty/. BFF proxy at app/api/loyalty/. Include loyalty frontend docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:50:55 +03:30

187 lines
7.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { Suspense, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
Button,
Dialog,
FormField,
Input,
PageHeader,
Card,
CardContent,
EmptyState,
Textarea,
} from "@/components/ds";
import { loyaltyApi } from "@/modules/loyalty/services/loyalty-api";
import { useTenantId } from "@/hooks/useTenantId";
import { LoyaltyBreadcrumbs } from "@/modules/loyalty/components/LoyaltyBreadcrumbs";
import { LoyaltyPageLoader, LoyaltyPageError } from "@/modules/loyalty/pages/shared";
import { LoyaltyStatusChip, LoyaltyTablePage } from "@/modules/loyalty/design-system";
const ACTIONS = [
{ id: "enroll", label: "ثبت‌نام" },
{ id: "activate", label: "فعال‌سازی" },
{ id: "renew", label: "تمدید" },
{ id: "freeze", label: "مسدود" },
{ id: "resume", label: "ازسرگیری" },
{ id: "cancel", label: "لغو" },
{ id: "expire", label: "انقضا" },
] as const;
function CustomerProfileInner() {
const { tenantId } = useTenantId();
const sp = useSearchParams();
const [memberId, setMemberId] = useState(sp.get("memberId") ?? "");
const [activeId, setActiveId] = useState(memberId);
const [action, setAction] = useState<(typeof ACTIONS)[number]["id"] | null>(null);
const [reason, setReason] = useState("");
const qc = useQueryClient();
const memberQ = useQuery({
queryKey: ["loyalty", tenantId, "member", activeId],
queryFn: () => loyaltyApi.members.get(tenantId!, activeId),
enabled: !!tenantId && !!activeId,
});
const lifeQ = useQuery({
queryKey: ["loyalty", tenantId, "member-lifecycle", activeId],
queryFn: () => loyaltyApi.members.lifecycle(tenantId!, activeId),
enabled: !!tenantId && !!activeId,
});
const actionM = useMutation({
mutationFn: async () => {
const body: Record<string, unknown> = {};
if (reason) body.reason = reason;
const fn = {
enroll: () => loyaltyApi.members.enroll(tenantId!, activeId, body),
activate: () => loyaltyApi.members.activate(tenantId!, activeId, body),
renew: () => loyaltyApi.members.renew(tenantId!, activeId, body),
freeze: () =>
loyaltyApi.members.freeze(tenantId!, activeId, { reason: reason || "freeze" }),
resume: () => loyaltyApi.members.resume(tenantId!, activeId, body),
cancel: () =>
loyaltyApi.members.cancel(tenantId!, activeId, { reason: reason || "cancel" }),
expire: () => loyaltyApi.members.expire(tenantId!, activeId, body),
}[action!];
return fn();
},
onSuccess: async () => {
toast.success("عملیات چرخه عمر انجام شد");
setAction(null);
setReason("");
await qc.invalidateQueries({ queryKey: ["loyalty", tenantId, "member"] });
await qc.invalidateQueries({ queryKey: ["loyalty", tenantId, "member-lifecycle"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId) return <LoyaltyPageLoader />;
return (
<div>
<LoyaltyBreadcrumbs items={[{ label: "پروفایل مشتری" }]} />
<PageHeader
title="پروفایل مشتری"
description="جزئیات عضو، چرخه عمر عضویت و اقدامات lifecycle."
/>
<div className="mb-6 flex flex-wrap gap-3">
<Input
className="max-w-md"
placeholder="شناسه عضو (UUID)"
value={memberId}
onChange={(e) => setMemberId(e.target.value)}
/>
<Button onClick={() => setActiveId(memberId.trim())}>بارگذاری</Button>
</div>
{!activeId ? <EmptyState title="شناسه عضو را وارد کنید" /> : null}
{activeId && memberQ.isLoading ? <LoyaltyPageLoader /> : null}
{memberQ.error ? (
<LoyaltyPageError error={memberQ.error} onRetry={() => memberQ.refetch()} />
) : null}
{memberQ.data ? (
<>
<Card className="mb-6">
<CardContent className="grid gap-3 pt-6 sm:grid-cols-2 lg:grid-cols-3">
<div>
<p className="text-xs text-[var(--muted)]">نام</p>
<p className="font-semibold">{memberQ.data.display_name}</p>
</div>
<div>
<p className="text-xs text-[var(--muted)]">شماره عضویت</p>
<p className="font-mono text-sm">{memberQ.data.membership_number}</p>
</div>
<div>
<p className="text-xs text-[var(--muted)]">وضعیت</p>
<LoyaltyStatusChip status={memberQ.data.status} domain="member" />
</div>
<div>
<p className="text-xs text-[var(--muted)]">ایمیل</p>
<p>{memberQ.data.email || "—"}</p>
</div>
<div>
<p className="text-xs text-[var(--muted)]">موبایل</p>
<p>{memberQ.data.mobile || "—"}</p>
</div>
<div>
<p className="text-xs text-[var(--muted)]">برنامه</p>
<p className="font-mono text-xs">{memberQ.data.program_id}</p>
</div>
</CardContent>
</Card>
<div className="mb-6 flex flex-wrap gap-2">
{ACTIONS.map((a) => (
<Button key={a.id} variant="outline" size="sm" onClick={() => setAction(a.id)}>
{a.label}
</Button>
))}
</div>
<LoyaltyTablePage
title="تاریخچه چرخه عمر"
columns={[
{ key: "action", header: "عمل" },
{ key: "from_status", header: "از" },
{ key: "to_status", header: "به" },
{ key: "reason", header: "دلیل" },
{ key: "created_at", header: "زمان" },
]}
rows={(lifeQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState title="رویدادی ثبت نشده" />}
/>
</>
) : null}
<Dialog open={!!action} onClose={() => setAction(null)} title="تأیید عملیات">
<div className="space-y-4">
<FormField label="دلیل">
<Textarea value={reason} onChange={(e) => setReason(e.target.value)} rows={3} />
</FormField>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setAction(null)}>
انصراف
</Button>
<Button loading={actionM.isPending} onClick={() => actionM.mutate()}>
تأیید
</Button>
</div>
</div>
</Dialog>
</div>
);
}
export default function CustomerProfilePage() {
return (
<Suspense fallback={<LoyaltyPageLoader />}>
<CustomerProfileInner />
</Suspense>
);
}