- Updated the AppShell and Sidebar components for improved layout and spacing. - Replaced font colors in the Logo component for better visibility. - Adjusted the PageLayout component to utilize backLink props instead of breadcrumbs for navigation consistency. - Removed unnecessary description props from multiple PageLayout instances across various views to simplify the codebase. - Introduced SectionCard component in OrderDetail for better organization of billing information. - Enhanced utility styles in CSS for improved typography and layout consistency.
393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* ID Verification Page
|
|
*
|
|
* This is a standalone page that can be accessed directly or from checkout flows.
|
|
* For checkout flows, it accepts a returnTo query parameter to navigate back.
|
|
*
|
|
* The main verification UI is now integrated into the Profile/Settings page.
|
|
* This page is kept for:
|
|
* - Direct URL access
|
|
* - Checkout flow redirects with returnTo parameter
|
|
*/
|
|
|
|
import { useMemo, useRef, useState } from "react";
|
|
import type { RefObject } from "react";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { PageLayout } from "@/components/templates/PageLayout";
|
|
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
|
|
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
|
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
|
|
import { Button } from "@/components/atoms/button";
|
|
import { StatusPill } from "@/components/atoms/status-pill";
|
|
import {
|
|
useResidenceCardVerification,
|
|
useSubmitResidenceCard,
|
|
} from "@/features/verification/hooks/useResidenceCardVerification";
|
|
|
|
function formatDateTime(iso?: string | null): string | null {
|
|
if (!iso) return null;
|
|
const date = new Date(iso);
|
|
if (Number.isNaN(date.getTime())) return null;
|
|
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(
|
|
date
|
|
);
|
|
}
|
|
|
|
// Shared timestamp display component
|
|
interface SubmissionTimestampProps {
|
|
label: string;
|
|
submittedAt?: string | null;
|
|
reviewedAt?: string | null;
|
|
}
|
|
|
|
function SubmissionTimestamp({
|
|
label,
|
|
submittedAt,
|
|
reviewedAt,
|
|
}: SubmissionTimestampProps): React.ReactElement {
|
|
return (
|
|
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3">
|
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
{label}
|
|
</div>
|
|
{formatDateTime(submittedAt) && (
|
|
<div className="mt-1 text-xs text-muted-foreground">
|
|
Submitted: {formatDateTime(submittedAt)}
|
|
</div>
|
|
)}
|
|
{formatDateTime(reviewedAt) && (
|
|
<div className="mt-1 text-xs text-muted-foreground">
|
|
Reviewed: {formatDateTime(reviewedAt)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Continue to checkout button used in multiple places
|
|
interface ContinueButtonProps {
|
|
returnTo: string;
|
|
onClick: () => void;
|
|
}
|
|
|
|
function ContinueButton({ returnTo, onClick }: ContinueButtonProps): React.ReactElement | null {
|
|
if (!returnTo) return null;
|
|
return (
|
|
<Button type="button" onClick={onClick}>
|
|
Continue to Checkout
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
// Loading state content
|
|
function LoadingContent(): React.ReactElement {
|
|
return <div className="text-sm text-muted-foreground">Checking verification status…</div>;
|
|
}
|
|
|
|
// Error state content
|
|
interface ErrorContentProps {
|
|
onRetry: () => void;
|
|
}
|
|
|
|
function ErrorContent({ onRetry }: ErrorContentProps): React.ReactElement {
|
|
return (
|
|
<AlertBanner variant="warning" title="Unable to load status" size="sm" elevated>
|
|
<div className="flex items-center gap-2">
|
|
<Button type="button" size="sm" onClick={onRetry}>
|
|
Try again
|
|
</Button>
|
|
</div>
|
|
</AlertBanner>
|
|
);
|
|
}
|
|
|
|
// Verified state content
|
|
interface VerifiedContentProps {
|
|
returnTo: string;
|
|
onNavigate: () => void;
|
|
}
|
|
|
|
function VerifiedContent({ returnTo, onNavigate }: VerifiedContentProps): React.ReactElement {
|
|
return (
|
|
<div className="space-y-4">
|
|
<AlertBanner variant="success" title="Verified" size="sm" elevated>
|
|
Your residence card is on file and approved. No further action is required.
|
|
</AlertBanner>
|
|
<ContinueButton returnTo={returnTo} onClick={onNavigate} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Pending state content
|
|
interface PendingContentProps {
|
|
submittedAt: string | null | undefined;
|
|
returnTo: string;
|
|
onNavigate: () => void;
|
|
}
|
|
|
|
function PendingContent({
|
|
submittedAt,
|
|
returnTo,
|
|
onNavigate,
|
|
}: PendingContentProps): React.ReactElement {
|
|
return (
|
|
<div className="space-y-4">
|
|
<AlertBanner variant="info" title="Under review" size="sm" elevated>
|
|
We'll verify your residence card before activating SIM service.
|
|
</AlertBanner>
|
|
{submittedAt && <SubmissionTimestamp label="Submission status" submittedAt={submittedAt} />}
|
|
<ContinueButton returnTo={returnTo} onClick={onNavigate} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Verification data type (inferred from hook return)
|
|
type VerificationData = {
|
|
status: "verified" | "rejected" | "pending" | "not_submitted";
|
|
submittedAt: string | null;
|
|
reviewedAt: string | null;
|
|
reviewerNotes: string | null;
|
|
};
|
|
|
|
// Upload form component
|
|
interface UploadFormProps {
|
|
residenceFile: File | null;
|
|
setResidenceFile: (file: File | null) => void;
|
|
residenceFileInputRef: RefObject<HTMLInputElement | null>;
|
|
submitResidenceCard: ReturnType<typeof useSubmitResidenceCard>;
|
|
returnTo: string;
|
|
router: ReturnType<typeof useRouter>;
|
|
}
|
|
|
|
function UploadForm({
|
|
residenceFile,
|
|
setResidenceFile,
|
|
residenceFileInputRef,
|
|
submitResidenceCard,
|
|
returnTo,
|
|
router,
|
|
}: UploadFormProps): React.ReactElement {
|
|
const clearFileInput = (): void => {
|
|
setResidenceFile(null);
|
|
if (residenceFileInputRef.current) {
|
|
residenceFileInputRef.current.value = "";
|
|
}
|
|
};
|
|
|
|
const handleSubmit = (): void => {
|
|
if (!residenceFile) return;
|
|
submitResidenceCard.mutate(residenceFile, {
|
|
onSuccess: () => {
|
|
clearFileInput();
|
|
if (returnTo) {
|
|
router.push(returnTo);
|
|
}
|
|
},
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<input
|
|
ref={residenceFileInputRef}
|
|
type="file"
|
|
accept="image/*,application/pdf"
|
|
onChange={e => setResidenceFile(e.target.files?.[0] ?? null)}
|
|
className="block w-full text-sm text-foreground file:mr-4 file:py-2 file:px-3 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-muted file:text-foreground hover:file:bg-muted/80"
|
|
/>
|
|
|
|
{residenceFile && (
|
|
<div className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
|
|
<div className="min-w-0">
|
|
<div className="text-xs font-medium text-muted-foreground">Selected file</div>
|
|
<div className="text-sm font-medium text-foreground truncate">{residenceFile.name}</div>
|
|
</div>
|
|
<Button type="button" variant="outline" size="sm" onClick={clearFileInput}>
|
|
Change
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-end gap-3">
|
|
{returnTo && (
|
|
<Button type="button" variant="outline" onClick={() => router.push(returnTo)}>
|
|
Back to checkout
|
|
</Button>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
disabled={!residenceFile || submitResidenceCard.isPending}
|
|
isLoading={submitResidenceCard.isPending}
|
|
loadingText="Uploading…"
|
|
onClick={handleSubmit}
|
|
>
|
|
Submit Document
|
|
</Button>
|
|
</div>
|
|
|
|
{submitResidenceCard.isError && (
|
|
<p className="text-sm text-destructive">
|
|
{submitResidenceCard.error instanceof Error
|
|
? submitResidenceCard.error.message
|
|
: "Failed to submit residence card."}
|
|
</p>
|
|
)}
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
Accepted formats: JPG, PNG, or PDF (max 5MB). Tip: higher resolution photos make review
|
|
faster.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Default state content (rejected or not_submitted)
|
|
interface DefaultContentProps {
|
|
status: VerificationData["status"] | undefined;
|
|
data: VerificationData | undefined;
|
|
residenceFile: File | null;
|
|
setResidenceFile: (file: File | null) => void;
|
|
residenceFileInputRef: RefObject<HTMLInputElement | null>;
|
|
submitResidenceCard: ReturnType<typeof useSubmitResidenceCard>;
|
|
canUpload: boolean;
|
|
returnTo: string;
|
|
router: ReturnType<typeof useRouter>;
|
|
}
|
|
|
|
function DefaultContent({
|
|
status,
|
|
data,
|
|
residenceFile,
|
|
setResidenceFile,
|
|
residenceFileInputRef,
|
|
submitResidenceCard,
|
|
canUpload,
|
|
returnTo,
|
|
router,
|
|
}: DefaultContentProps): React.ReactElement {
|
|
const isRejected = status === "rejected";
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<AlertBanner
|
|
variant={isRejected ? "warning" : "info"}
|
|
title={isRejected ? "Verification rejected" : "Upload required"}
|
|
size="sm"
|
|
elevated
|
|
>
|
|
<div className="space-y-2 text-sm">
|
|
{isRejected && data?.reviewerNotes && <p>{data.reviewerNotes}</p>}
|
|
<p>Upload a clear photo or scan of your residence card (JPG, PNG, or PDF).</p>
|
|
<ul className="list-disc space-y-1 pl-5 text-sm text-muted-foreground">
|
|
<li>Make sure all text is readable and the full card is visible.</li>
|
|
<li>Avoid glare/reflections and blurry photos.</li>
|
|
<li>Maximum file size: 5MB.</li>
|
|
</ul>
|
|
</div>
|
|
</AlertBanner>
|
|
|
|
{(data?.submittedAt || data?.reviewedAt) && (
|
|
<SubmissionTimestamp
|
|
label="Latest submission"
|
|
submittedAt={data?.submittedAt}
|
|
reviewedAt={data?.reviewedAt}
|
|
/>
|
|
)}
|
|
|
|
{canUpload && (
|
|
<UploadForm
|
|
residenceFile={residenceFile}
|
|
setResidenceFile={setResidenceFile}
|
|
residenceFileInputRef={residenceFileInputRef}
|
|
submitResidenceCard={submitResidenceCard}
|
|
returnTo={returnTo}
|
|
router={router}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ResidenceCardVerificationSettingsView(): React.ReactElement {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const returnTo = searchParams?.get("returnTo")?.trim() || "";
|
|
|
|
const residenceCardQuery = useResidenceCardVerification({ enabled: true });
|
|
const submitResidenceCard = useSubmitResidenceCard();
|
|
|
|
const [residenceFile, setResidenceFile] = useState<File | null>(null);
|
|
const residenceFileInputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
const status = residenceCardQuery.data?.status;
|
|
const statusPill = useMemo(() => {
|
|
if (status === "verified") return <StatusPill label="Verified" variant="success" />;
|
|
if (status === "pending") return <StatusPill label="Under Review" variant="info" />;
|
|
if (status === "rejected") return <StatusPill label="Action Needed" variant="warning" />;
|
|
return <StatusPill label="Not Submitted" variant="warning" />;
|
|
}, [status]);
|
|
|
|
const canUpload = status !== "verified";
|
|
const handleNavigate = (): void => router.push(returnTo);
|
|
|
|
const renderVerificationContent = (): React.ReactElement => {
|
|
if (residenceCardQuery.isLoading) {
|
|
return <LoadingContent />;
|
|
}
|
|
|
|
if (residenceCardQuery.isError) {
|
|
return <ErrorContent onRetry={() => void residenceCardQuery.refetch()} />;
|
|
}
|
|
|
|
if (status === "verified") {
|
|
return <VerifiedContent returnTo={returnTo} onNavigate={handleNavigate} />;
|
|
}
|
|
|
|
if (status === "pending") {
|
|
return (
|
|
<PendingContent
|
|
submittedAt={residenceCardQuery.data?.submittedAt}
|
|
returnTo={returnTo}
|
|
onNavigate={handleNavigate}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<DefaultContent
|
|
status={status}
|
|
data={residenceCardQuery.data}
|
|
residenceFile={residenceFile}
|
|
setResidenceFile={setResidenceFile}
|
|
residenceFileInputRef={residenceFileInputRef}
|
|
submitResidenceCard={submitResidenceCard}
|
|
canUpload={canUpload}
|
|
returnTo={returnTo}
|
|
router={router}
|
|
/>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<PageLayout
|
|
title="ID Verification"
|
|
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
|
backLink={{ label: "Back to Settings", href: "/account/settings" }}
|
|
>
|
|
<div className="max-w-2xl mx-auto space-y-6">
|
|
<SubCard
|
|
title="Residence Card"
|
|
icon={<ShieldCheckIcon className="w-5 h-5 text-primary" />}
|
|
right={statusPill}
|
|
>
|
|
{renderVerificationContent()}
|
|
</SubCard>
|
|
</div>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
export default ResidenceCardVerificationSettingsView;
|