Assist_Design/apps/portal/src/features/verification/views/ResidenceCardVerificationSettingsView.tsx
barsa cab58d1c5b refactor: streamline component layouts and enhance navigation
- 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.
2026-03-06 10:45:51 +09:00

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&apos;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;