Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 35 additions & 11 deletions frontend/src/app/(authenticated)/kyc/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,35 @@
import { Metadata } from "next";
import KycPageContent from "@/components/KycPageContent";

export const metadata: Metadata = {
title: "KYC Verification | PLUTO",
description: "Complete your KYC verification to unlock full platform features",
};

export default function KycPage() {
return <KycPageContent />;
}
/**
* KYC Verification Page — React Server Component
*
* RSC migration:
* - generateMetadata uses next-intl's getTranslations so title/description
* are resolved from the active locale on the server, no client JS needed.
* - The page itself is a pure async Server Component: zero client bundle cost.
* - KycPageContent is also an RSC; the interactive form is pushed down to the
* minimum "use client" leaf (KycSubmissionForm).
*/

import { type Metadata } from "next";
import { getTranslations } from "next-intl/server";
import KycPageContent from "@/components/KycPageContent";

// ── i18n-aware metadata ───────────────────────────────────────────────────────

export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("kycPage");

return {
title: `${t("title")} | PLUTO`,
description: t("description"),
openGraph: {
title: `${t("title")} | PLUTO`,
description: t("description"),
},
};
}

// ── Page — pure RSC ───────────────────────────────────────────────────────────

export default async function KycPage() {
return <KycPageContent />;
}
69 changes: 69 additions & 0 deletions frontend/src/components/KycFormShell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* KycFormShell — React Server Component
*
* Renders the static chrome around the KYC form on the server:
* - Card border/background container
* - Any server-resolved props forwarded to the client form
*
* The interactive form (KycSubmissionForm) is imported as a dynamic client
* component wrapped in <Suspense> so the static shell streams immediately
* while the client bundle loads. KycFormSkeleton is the Suspense fallback.
*
* Why a separate shell instead of inlining in KycPageContent?
* - Isolates the Suspense boundary so only the form stream is deferred.
* - Makes the "use client" boundary explicit and easy to audit.
* - Allows passing serialisable server-resolved props (locale, initial values
* from session, feature flags) to the client form without a round-trip.
*/

import { Suspense } from "react";
import { getTranslations } from "next-intl/server";
import dynamic from "next/dynamic";
import KycFormSkeleton from "@/components/KycFormSkeleton";
import type { KycInitialValues } from "@/components/KycSubmissionForm";

// Lazy-load the client form — only sent to the browser when needed.
// ssr: false ensures it never runs during server rendering (it uses browser
// APIs like useId, useState, useReducer).
const KycSubmissionForm = dynamic(
() => import("@/components/KycSubmissionForm"),
{
ssr: false,
loading: () => <KycFormSkeleton />,
},
);

// ── Props ─────────────────────────────────────────────────────────────────────

interface KycFormShellProps {
/**
* Optional server-resolved initial values (e.g. pre-filled from a session
* or a previous incomplete submission). Only serialisable primitives — no
* File objects.
*/
initialValues?: KycInitialValues;
}

// ── Component ─────────────────────────────────────────────────────────────────

export default async function KycFormShell({ initialValues }: KycFormShellProps) {
// Resolve the form title server-side for the accessible landmark label.
const t = await getTranslations("kycForm");
const formTitle = t("formTitle");

return (
/*
* The outer div gives the shell its visual card styling.
* The role/aria-label is forwarded as a data attribute so the client
* form can apply it on mount without a hydration mismatch.
*/
<div
className="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur"
data-form-title={formTitle}
>
<Suspense fallback={<KycFormSkeleton />}>
<KycSubmissionForm initialValues={initialValues} />
</Suspense>
</div>
);
}
111 changes: 111 additions & 0 deletions frontend/src/components/KycFormSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* KycFormSkeleton
*
* Server-safe Suspense fallback rendered while the KycSubmissionForm client
* bundle is streaming in. Uses plain CSS classes (no framer-motion, no hooks)
* so it is safe to render in a Server Component or as a Suspense boundary
* fallback.
*
* Structure mirrors the real form so the layout shift on hydration is minimal:
* - Progress bar row
* - Step indicator dots
* - Skeleton field rows (title + 4 fields)
* - Navigation button row
*/

import React from "react";

// ── Shimmer bone ──────────────────────────────────────────────────────────────

function Bone({ className = "" }: { className?: string }) {
return (
<div
className={`kyc-shimmer rounded-lg ${className}`}
aria-hidden="true"
/>
);
}

// ── Skeleton step dot ─────────────────────────────────────────────────────────

function StepDot({ active }: { active: boolean }) {
return (
<div
className={`h-2 flex-1 rounded-full ${
active ? "bg-pluto-600 dark:bg-pluto-400" : "bg-pluto-100 dark:bg-pluto-800"
}`}
aria-hidden="true"
/>
);
}

// ── Main skeleton ─────────────────────────────────────────────────────────────

export default function KycFormSkeleton() {
return (
<div
className="w-full max-w-2xl mx-auto"
role="status"
aria-label="Loading KYC form…"
aria-busy="true"
data-testid="kyc-form-skeleton"
>
{/* sr-only label for screen readers */}
<span className="sr-only">Loading KYC form…</span>

<div className="rounded-3xl border border-pluto-100 bg-white p-6 shadow-lg sm:p-8 dark:border-pluto-800/60 dark:bg-pluto-900/80 space-y-6">

{/* ── Progress bar skeleton ─────────────────────────────────────── */}
<div className="space-y-2" aria-hidden="true">
{/* "X of 4" label row */}
<div className="flex items-center justify-between">
<Bone className="h-3 w-10" />
</div>
{/* Step dots */}
<div className="flex gap-1.5">
<StepDot active />
<StepDot active={false} />
<StepDot active={false} />
<StepDot active={false} />
</div>
</div>

{/* ── Step content skeleton ─────────────────────────────────────── */}
<div className="space-y-4" aria-hidden="true">
{/* Section heading */}
<Bone className="h-6 w-48" />

{/* Two-column name row */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Bone className="h-3.5 w-20" />
<Bone className="h-11 w-full" />
</div>
<div className="space-y-1.5">
<Bone className="h-3.5 w-20" />
<Bone className="h-11 w-full" />
</div>
</div>

{/* Full-width email row */}
<div className="space-y-1.5">
<Bone className="h-3.5 w-16" />
<Bone className="h-11 w-full" />
</div>

{/* Full-width date row */}
<div className="space-y-1.5">
<Bone className="h-3.5 w-24" />
<Bone className="h-11 w-full" />
</div>
</div>

{/* ── Navigation button skeleton ────────────────────────────────── */}
<div className="flex gap-3 pt-1" aria-hidden="true">
<Bone className="h-12 flex-1 rounded-xl" />
<Bone className="h-12 flex-1 rounded-xl" />
</div>
</div>
</div>
);
}
93 changes: 62 additions & 31 deletions frontend/src/components/KycPageContent.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,62 @@
"use client";

import { useTranslations } from "next-intl";
import KycSubmissionForm from "@/components/KycSubmissionForm";

export default function KycPageContent() {
const t = useTranslations("kycPage");

return (
<div className="mx-auto max-w-2xl space-y-6 p-6">
<div className="space-y-2">
<h1 className="text-3xl font-bold text-white">{t("title")}</h1>
<p className="text-slate-400">{t("description")}</p>
</div>

<div className="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur">
<KycSubmissionForm />
</div>

<div className="rounded-xl border border-blue-500/30 bg-blue-500/10 p-4">
<h3 className="mb-2 font-semibold text-blue-400">{t("whyKyc")}</h3>
<ul className="space-y-1 text-sm text-slate-400">
<li>• {t("reasonComply")}</li>
<li>• {t("reasonLimits")}</li>
<li>• {t("reasonFeatures")}</li>
<li>• {t("reasonSecurity")}</li>
</ul>
</div>
</div>
);
}
/**
* KycPageContent — React Server Component
*
* RSC migration:
* - Removed "use client" directive. This component runs exclusively on the
* server; it emits zero client JavaScript.
* - useTranslations (client hook) replaced with getTranslations (server util).
* - Static content (heading, description, why-kyc list) is fully server-rendered
* HTML — no hydration cost.
* - The interactive form is mounted via KycFormShell, which owns the Suspense
* boundary and the dynamic() import of the client leaf.
*
* Rendering tree:
* KycPage (RSC, async)
* └── KycPageContent (RSC, async)
* ├── <header> — static HTML, server-rendered
* ├── KycFormShell (RSC, async)
* │ └── <Suspense fallback={<KycFormSkeleton />}>
* │ └── KycSubmissionForm (Client Component, lazy)
* └── <aside> — static HTML, server-rendered
*/

import { getTranslations } from "next-intl/server";
import KycFormShell from "@/components/KycFormShell";

export default async function KycPageContent() {
const t = await getTranslations("kycPage");

return (
<div className="mx-auto max-w-2xl space-y-6 p-6">

{/* ── Page header — fully server-rendered ──────────────────────────── */}
<header className="space-y-2">
<h1 className="text-3xl font-bold text-white">{t("title")}</h1>
<p className="text-slate-400">{t("description")}</p>
</header>

{/* ── Interactive form — client leaf behind Suspense ────────────────── */}
<KycFormShell />

{/* ── Why KYC aside — fully server-rendered ────────────────────────── */}
<aside
className="rounded-xl border border-blue-500/30 bg-blue-500/10 p-4"
aria-labelledby="why-kyc-heading"
>
<h2
id="why-kyc-heading"
className="mb-2 font-semibold text-blue-400"
>
{t("whyKyc")}
</h2>
<ul className="space-y-1 text-sm text-slate-400" role="list">
<li>• {t("reasonComply")}</li>
<li>• {t("reasonLimits")}</li>
<li>• {t("reasonFeatures")}</li>
<li>• {t("reasonSecurity")}</li>
</ul>
</aside>

</div>
);
}
52 changes: 50 additions & 2 deletions frontend/src/components/KycSubmissionForm.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
"use client";

/**
* KycSubmissionForm — Client Component (minimum client boundary)
*
* RSC migration notes:
* - This is the ONLY "use client" file in the KYC feature tree.
* - All static chrome (page heading, why-KYC aside) was moved to the RSC
* layer (KycPageContent / KycFormShell) and never ships as client JS.
* - Accepts `initialValues` prop so the server can pre-populate fields from
* a session or a previous incomplete submission without a client round-trip.
* - The component is lazy-loaded via dynamic() in KycFormShell and wrapped
* in <Suspense> so the static shell streams before this bundle is sent.
*/

import React, { useReducer, useCallback, useState, useId } from "react";
import { motion, AnimatePresence, type Variants } from "framer-motion";
import { useTranslations } from "next-intl";
Expand All @@ -8,8 +21,21 @@ import {
kycFlowReducer,
initialKycFlowState,
type KycStep,
type KycFlowState,
} from "@/lib/kyc-flow";

// ── Serialisable initial-values type (no File objects — safe to pass from RSC) ─

export interface KycInitialValues {
personal?: Partial<KycFlowState["personal"]>;
address?: Partial<KycFlowState["address"]>;
documents?: {
idType?: KycFlowState["documents"]["idType"];
idNumber?: string;
};
currentStep?: KycStep;
}

const STEPS: KycStep[] = ["personal", "address", "documents", "review"];
const TOTAL_STEPS = STEPS.length;

Expand Down Expand Up @@ -83,10 +109,32 @@ function Field({
);
}

function KycSubmissionForm() {
function KycSubmissionForm({ initialValues }: { initialValues?: KycInitialValues }) {
const t = useTranslations("kycForm");
const uid = useId();
const [state, dispatch] = useReducer(kycFlowReducer, initialKycFlowState);

// Merge server-supplied initial values into the default state so the form
// is pre-populated when the server passes session data.
const mergedInitialState: typeof initialKycFlowState = {
...initialKycFlowState,
...(initialValues?.currentStep
? { currentStep: initialValues.currentStep }
: {}),
personal: {
...initialKycFlowState.personal,
...initialValues?.personal,
},
address: {
...initialKycFlowState.address,
...initialValues?.address,
},
documents: {
...initialKycFlowState.documents,
...initialValues?.documents,
},
};

const [state, dispatch] = useReducer(kycFlowReducer, mergedInitialState);
const [direction, setDirection] = useState(1);
const [announcement, setAnnouncement] = useState("");
const [stepErrors, setStepErrors] = useState<Record<string, string>>({});
Expand Down
Loading