diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx new file mode 100644 index 0000000..8feccff --- /dev/null +++ b/frontend/src/app/admin/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { Suspense } from "react"; +import { AdminStudio } from "@/components/admin-studio"; + +export default function AdminPage() { + return ( + + + + ); +} \ No newline at end of file diff --git a/frontend/src/app/apple-icon.png b/frontend/src/app/apple-icon.png new file mode 100644 index 0000000..dec1f08 Binary files /dev/null and b/frontend/src/app/apple-icon.png differ diff --git a/frontend/src/app/c/[id]/page.tsx b/frontend/src/app/c/[id]/page.tsx new file mode 100644 index 0000000..f3abddc --- /dev/null +++ b/frontend/src/app/c/[id]/page.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { Loader2, Package } from "lucide-react"; +import { CertificateCard } from "@/components/certificate-card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { http, toUserError } from "@/lib/api"; +import { useAuth } from "@/lib/auth-store"; +import type { Certificate, CertificatePhysicalOrder, PhysicalCertSettings } from "@/lib/types"; +import { toast } from "@/components/providers"; +import { formatFaNumber } from "@/lib/utils"; +import { clampPhoneInput, normalizePhone, PHONE_INPUT_MAX_LEN } from "@/lib/validation"; +import { useEffect, useState } from "react"; + +type PublicCertResponse = { + certificate: { publicId: string; fullName: string; issuedAt: string }; + course: string; +}; + +const STATUS_LABEL: Record = { + requested: "درخواست‌شده — منتظر تأیید پرداخت", + paid: "پرداخت تأیید شد — در صف ارسال", + shipped: "ارسال شد", + cancelled: "لغو شده", +}; + +export default function CertificatePage() { + const params = useParams<{ id: string }>(); + const publicId = params?.id ?? ""; + const authUser = useAuth((s) => s.user); + const qc = useQueryClient(); + const [requesting, setRequesting] = useState(false); + const [form, setForm] = useState({ + recipientName: "", + phone: "", + address: "", + city: "", + postalCode: "", + }); + + const { data, isLoading, error } = useQuery({ + queryKey: ["certificate", publicId], + queryFn: () => http.get(`/api/certificates/${publicId}`), + enabled: !!publicId, + meta: { silentError: true }, + }); + + const { data: mine } = useQuery({ + queryKey: ["me", "certificate"], + queryFn: () => + http.get<{ + certificate: Certificate | null; + physicalOrder: CertificatePhysicalOrder | null; + physical: PhysicalCertSettings; + }>("/api/me/certificate"), + enabled: !!authUser, + }); + + const isOwner = !!authUser && mine?.certificate?.publicId === publicId; + + useEffect(() => { + if (!isOwner || !authUser) return; + setForm((f) => ({ + ...f, + recipientName: f.recipientName || authUser.name || "", + phone: f.phone || authUser.phone || "", + })); + }, [isOwner, authUser]); + + const requestPhysical = async () => { + setRequesting(true); + try { + await http.post("/api/me/certificate/physical", { + ...form, + phone: normalizePhone(form.phone), + }); + toast.success("درخواست ثبت شد — پس از واریز با ادمین هماهنگ کنید"); + qc.invalidateQueries({ queryKey: ["me", "certificate"] }); + } catch (e) { + toast.error(toUserError(e)); + } finally { + setRequesting(false); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error || !data?.certificate) { + return ( +
+

گواهینامه پیدا نشد

+ +
+ ); + } + + const cert = data.certificate; + const order = isOwner ? mine?.physicalOrder : null; + const physical = isOwner ? mine?.physical : null; + const windowEndsAt = mine?.certificate?.issuedAt + ? new Date(mine.certificate.issuedAt).getTime() + (physical?.windowDays ?? 30) * 86400000 + : 0; + const windowStillOpen = !windowEndsAt || Date.now() < windowEndsAt; + const windowOpen = + physical?.enabled && + windowStillOpen && + (!order || order.status === "cancelled") && + !!mine?.certificate; + + return ( + + {isOwner && physical?.enabled && ( +
+

+ نسخه فیزیکی +

+

+ قیمت حدودی {formatFaNumber(physical.priceIrr)} ریال · مهلت درخواست{" "} + {formatFaNumber(physical.windowDays)} روز از صدور + {windowEndsAt ? ` (تا ${new Date(windowEndsAt).toLocaleDateString("fa-IR")})` : ""}. + پرداخت آنلاین در سامانه نداریم. پس از واریز بیرونی با ادمین هماهنگ کنید تا وضعیت سفارش به‌روز شود. +

+ {order && order.status !== "cancelled" ? ( +
+

+ وضعیت: {STATUS_LABEL[order.status] ?? order.status} +

+ {order.trackingCode && ( +

+ کد پیگیری: {order.trackingCode} +

+ )} + {order.city && ( +

+ ارسال به {order.recipientName || "—"} · {order.city} +

+ )} + {order.note &&

یادداشت ادمین: {order.note}

} + +
+ ) : windowOpen ? ( +
+
+ setForm((f) => ({ ...f, recipientName: e.target.value }))} + /> + setForm((f) => ({ ...f, phone: clampPhoneInput(e.target.value) }))} + dir="ltr" + inputMode="tel" + maxLength={PHONE_INPUT_MAX_LEN} + className="text-left" + /> + setForm((f) => ({ ...f, city: e.target.value }))} + /> + setForm((f) => ({ ...f, postalCode: e.target.value }))} + dir="ltr" + /> +
+