Skip to content

Commit e94d5a8

Browse files
authored
Merge pull request #1267 from emmanuelStack654/feature/fe-migrate-component-to-react-server-components-for-kyc-submission-form
feat(frontend): migrate component to React Server Components for KYC …
2 parents 4b9caf0 + 89ae8d5 commit e94d5a8

5 files changed

Lines changed: 327 additions & 44 deletions

File tree

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,35 @@
1-
import { Metadata } from "next";
2-
import KycPageContent from "@/components/KycPageContent";
3-
4-
export const metadata: Metadata = {
5-
title: "KYC Verification | PLUTO",
6-
description: "Complete your KYC verification to unlock full platform features",
7-
};
8-
9-
export default function KycPage() {
10-
return <KycPageContent />;
11-
}
1+
/**
2+
* KYC Verification Page — React Server Component
3+
*
4+
* RSC migration:
5+
* - generateMetadata uses next-intl's getTranslations so title/description
6+
* are resolved from the active locale on the server, no client JS needed.
7+
* - The page itself is a pure async Server Component: zero client bundle cost.
8+
* - KycPageContent is also an RSC; the interactive form is pushed down to the
9+
* minimum "use client" leaf (KycSubmissionForm).
10+
*/
11+
12+
import { type Metadata } from "next";
13+
import { getTranslations } from "next-intl/server";
14+
import KycPageContent from "@/components/KycPageContent";
15+
16+
// ── i18n-aware metadata ───────────────────────────────────────────────────────
17+
18+
export async function generateMetadata(): Promise<Metadata> {
19+
const t = await getTranslations("kycPage");
20+
21+
return {
22+
title: `${t("title")} | PLUTO`,
23+
description: t("description"),
24+
openGraph: {
25+
title: `${t("title")} | PLUTO`,
26+
description: t("description"),
27+
},
28+
};
29+
}
30+
31+
// ── Page — pure RSC ───────────────────────────────────────────────────────────
32+
33+
export default async function KycPage() {
34+
return <KycPageContent />;
35+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* KycFormShell — React Server Component
3+
*
4+
* Renders the static chrome around the KYC form on the server:
5+
* - Card border/background container
6+
* - Any server-resolved props forwarded to the client form
7+
*
8+
* The interactive form (KycSubmissionForm) is imported as a dynamic client
9+
* component wrapped in <Suspense> so the static shell streams immediately
10+
* while the client bundle loads. KycFormSkeleton is the Suspense fallback.
11+
*
12+
* Why a separate shell instead of inlining in KycPageContent?
13+
* - Isolates the Suspense boundary so only the form stream is deferred.
14+
* - Makes the "use client" boundary explicit and easy to audit.
15+
* - Allows passing serialisable server-resolved props (locale, initial values
16+
* from session, feature flags) to the client form without a round-trip.
17+
*/
18+
19+
import { Suspense } from "react";
20+
import { getTranslations } from "next-intl/server";
21+
import dynamic from "next/dynamic";
22+
import KycFormSkeleton from "@/components/KycFormSkeleton";
23+
import type { KycInitialValues } from "@/components/KycSubmissionForm";
24+
25+
// Lazy-load the client form — only sent to the browser when needed.
26+
// ssr: false ensures it never runs during server rendering (it uses browser
27+
// APIs like useId, useState, useReducer).
28+
const KycSubmissionForm = dynamic(
29+
() => import("@/components/KycSubmissionForm"),
30+
{
31+
ssr: false,
32+
loading: () => <KycFormSkeleton />,
33+
},
34+
);
35+
36+
// ── Props ─────────────────────────────────────────────────────────────────────
37+
38+
interface KycFormShellProps {
39+
/**
40+
* Optional server-resolved initial values (e.g. pre-filled from a session
41+
* or a previous incomplete submission). Only serialisable primitives — no
42+
* File objects.
43+
*/
44+
initialValues?: KycInitialValues;
45+
}
46+
47+
// ── Component ─────────────────────────────────────────────────────────────────
48+
49+
export default async function KycFormShell({ initialValues }: KycFormShellProps) {
50+
// Resolve the form title server-side for the accessible landmark label.
51+
const t = await getTranslations("kycForm");
52+
const formTitle = t("formTitle");
53+
54+
return (
55+
/*
56+
* The outer div gives the shell its visual card styling.
57+
* The role/aria-label is forwarded as a data attribute so the client
58+
* form can apply it on mount without a hydration mismatch.
59+
*/
60+
<div
61+
className="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur"
62+
data-form-title={formTitle}
63+
>
64+
<Suspense fallback={<KycFormSkeleton />}>
65+
<KycSubmissionForm initialValues={initialValues} />
66+
</Suspense>
67+
</div>
68+
);
69+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* KycFormSkeleton
3+
*
4+
* Server-safe Suspense fallback rendered while the KycSubmissionForm client
5+
* bundle is streaming in. Uses plain CSS classes (no framer-motion, no hooks)
6+
* so it is safe to render in a Server Component or as a Suspense boundary
7+
* fallback.
8+
*
9+
* Structure mirrors the real form so the layout shift on hydration is minimal:
10+
* - Progress bar row
11+
* - Step indicator dots
12+
* - Skeleton field rows (title + 4 fields)
13+
* - Navigation button row
14+
*/
15+
16+
import React from "react";
17+
18+
// ── Shimmer bone ──────────────────────────────────────────────────────────────
19+
20+
function Bone({ className = "" }: { className?: string }) {
21+
return (
22+
<div
23+
className={`kyc-shimmer rounded-lg ${className}`}
24+
aria-hidden="true"
25+
/>
26+
);
27+
}
28+
29+
// ── Skeleton step dot ─────────────────────────────────────────────────────────
30+
31+
function StepDot({ active }: { active: boolean }) {
32+
return (
33+
<div
34+
className={`h-2 flex-1 rounded-full ${
35+
active ? "bg-pluto-600 dark:bg-pluto-400" : "bg-pluto-100 dark:bg-pluto-800"
36+
}`}
37+
aria-hidden="true"
38+
/>
39+
);
40+
}
41+
42+
// ── Main skeleton ─────────────────────────────────────────────────────────────
43+
44+
export default function KycFormSkeleton() {
45+
return (
46+
<div
47+
className="w-full max-w-2xl mx-auto"
48+
role="status"
49+
aria-label="Loading KYC form…"
50+
aria-busy="true"
51+
data-testid="kyc-form-skeleton"
52+
>
53+
{/* sr-only label for screen readers */}
54+
<span className="sr-only">Loading KYC form…</span>
55+
56+
<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">
57+
58+
{/* ── Progress bar skeleton ─────────────────────────────────────── */}
59+
<div className="space-y-2" aria-hidden="true">
60+
{/* "X of 4" label row */}
61+
<div className="flex items-center justify-between">
62+
<Bone className="h-3 w-10" />
63+
</div>
64+
{/* Step dots */}
65+
<div className="flex gap-1.5">
66+
<StepDot active />
67+
<StepDot active={false} />
68+
<StepDot active={false} />
69+
<StepDot active={false} />
70+
</div>
71+
</div>
72+
73+
{/* ── Step content skeleton ─────────────────────────────────────── */}
74+
<div className="space-y-4" aria-hidden="true">
75+
{/* Section heading */}
76+
<Bone className="h-6 w-48" />
77+
78+
{/* Two-column name row */}
79+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
80+
<div className="space-y-1.5">
81+
<Bone className="h-3.5 w-20" />
82+
<Bone className="h-11 w-full" />
83+
</div>
84+
<div className="space-y-1.5">
85+
<Bone className="h-3.5 w-20" />
86+
<Bone className="h-11 w-full" />
87+
</div>
88+
</div>
89+
90+
{/* Full-width email row */}
91+
<div className="space-y-1.5">
92+
<Bone className="h-3.5 w-16" />
93+
<Bone className="h-11 w-full" />
94+
</div>
95+
96+
{/* Full-width date row */}
97+
<div className="space-y-1.5">
98+
<Bone className="h-3.5 w-24" />
99+
<Bone className="h-11 w-full" />
100+
</div>
101+
</div>
102+
103+
{/* ── Navigation button skeleton ────────────────────────────────── */}
104+
<div className="flex gap-3 pt-1" aria-hidden="true">
105+
<Bone className="h-12 flex-1 rounded-xl" />
106+
<Bone className="h-12 flex-1 rounded-xl" />
107+
</div>
108+
</div>
109+
</div>
110+
);
111+
}
Lines changed: 62 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,62 @@
1-
"use client";
2-
3-
import { useTranslations } from "next-intl";
4-
import KycSubmissionForm from "@/components/KycSubmissionForm";
5-
6-
export default function KycPageContent() {
7-
const t = useTranslations("kycPage");
8-
9-
return (
10-
<div className="mx-auto max-w-2xl space-y-6 p-6">
11-
<div className="space-y-2">
12-
<h1 className="text-3xl font-bold text-white">{t("title")}</h1>
13-
<p className="text-slate-400">{t("description")}</p>
14-
</div>
15-
16-
<div className="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur">
17-
<KycSubmissionForm />
18-
</div>
19-
20-
<div className="rounded-xl border border-blue-500/30 bg-blue-500/10 p-4">
21-
<h3 className="mb-2 font-semibold text-blue-400">{t("whyKyc")}</h3>
22-
<ul className="space-y-1 text-sm text-slate-400">
23-
<li>{t("reasonComply")}</li>
24-
<li>{t("reasonLimits")}</li>
25-
<li>{t("reasonFeatures")}</li>
26-
<li>{t("reasonSecurity")}</li>
27-
</ul>
28-
</div>
29-
</div>
30-
);
31-
}
1+
/**
2+
* KycPageContent — React Server Component
3+
*
4+
* RSC migration:
5+
* - Removed "use client" directive. This component runs exclusively on the
6+
* server; it emits zero client JavaScript.
7+
* - useTranslations (client hook) replaced with getTranslations (server util).
8+
* - Static content (heading, description, why-kyc list) is fully server-rendered
9+
* HTML — no hydration cost.
10+
* - The interactive form is mounted via KycFormShell, which owns the Suspense
11+
* boundary and the dynamic() import of the client leaf.
12+
*
13+
* Rendering tree:
14+
* KycPage (RSC, async)
15+
* └── KycPageContent (RSC, async)
16+
* ├── <header> — static HTML, server-rendered
17+
* ├── KycFormShell (RSC, async)
18+
* │ └── <Suspense fallback={<KycFormSkeleton />}>
19+
* │ └── KycSubmissionForm (Client Component, lazy)
20+
* └── <aside> — static HTML, server-rendered
21+
*/
22+
23+
import { getTranslations } from "next-intl/server";
24+
import KycFormShell from "@/components/KycFormShell";
25+
26+
export default async function KycPageContent() {
27+
const t = await getTranslations("kycPage");
28+
29+
return (
30+
<div className="mx-auto max-w-2xl space-y-6 p-6">
31+
32+
{/* ── Page header — fully server-rendered ──────────────────────────── */}
33+
<header className="space-y-2">
34+
<h1 className="text-3xl font-bold text-white">{t("title")}</h1>
35+
<p className="text-slate-400">{t("description")}</p>
36+
</header>
37+
38+
{/* ── Interactive form — client leaf behind Suspense ────────────────── */}
39+
<KycFormShell />
40+
41+
{/* ── Why KYC aside — fully server-rendered ────────────────────────── */}
42+
<aside
43+
className="rounded-xl border border-blue-500/30 bg-blue-500/10 p-4"
44+
aria-labelledby="why-kyc-heading"
45+
>
46+
<h2
47+
id="why-kyc-heading"
48+
className="mb-2 font-semibold text-blue-400"
49+
>
50+
{t("whyKyc")}
51+
</h2>
52+
<ul className="space-y-1 text-sm text-slate-400" role="list">
53+
<li>{t("reasonComply")}</li>
54+
<li>{t("reasonLimits")}</li>
55+
<li>{t("reasonFeatures")}</li>
56+
<li>{t("reasonSecurity")}</li>
57+
</ul>
58+
</aside>
59+
60+
</div>
61+
);
62+
}

frontend/src/components/KycSubmissionForm.tsx

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
"use client";
22

3+
/**
4+
* KycSubmissionForm — Client Component (minimum client boundary)
5+
*
6+
* RSC migration notes:
7+
* - This is the ONLY "use client" file in the KYC feature tree.
8+
* - All static chrome (page heading, why-KYC aside) was moved to the RSC
9+
* layer (KycPageContent / KycFormShell) and never ships as client JS.
10+
* - Accepts `initialValues` prop so the server can pre-populate fields from
11+
* a session or a previous incomplete submission without a client round-trip.
12+
* - The component is lazy-loaded via dynamic() in KycFormShell and wrapped
13+
* in <Suspense> so the static shell streams before this bundle is sent.
14+
*/
15+
316
import React, { useReducer, useCallback, useState, useId } from "react";
417
import { motion, AnimatePresence, type Variants } from "framer-motion";
518
import { useTranslations } from "next-intl";
@@ -8,8 +21,21 @@ import {
821
kycFlowReducer,
922
initialKycFlowState,
1023
type KycStep,
24+
type KycFlowState,
1125
} from "@/lib/kyc-flow";
1226

27+
// ── Serialisable initial-values type (no File objects — safe to pass from RSC) ─
28+
29+
export interface KycInitialValues {
30+
personal?: Partial<KycFlowState["personal"]>;
31+
address?: Partial<KycFlowState["address"]>;
32+
documents?: {
33+
idType?: KycFlowState["documents"]["idType"];
34+
idNumber?: string;
35+
};
36+
currentStep?: KycStep;
37+
}
38+
1339
const STEPS: KycStep[] = ["personal", "address", "documents", "review"];
1440
const TOTAL_STEPS = STEPS.length;
1541

@@ -83,10 +109,32 @@ function Field({
83109
);
84110
}
85111

86-
function KycSubmissionForm() {
112+
function KycSubmissionForm({ initialValues }: { initialValues?: KycInitialValues }) {
87113
const t = useTranslations("kycForm");
88114
const uid = useId();
89-
const [state, dispatch] = useReducer(kycFlowReducer, initialKycFlowState);
115+
116+
// Merge server-supplied initial values into the default state so the form
117+
// is pre-populated when the server passes session data.
118+
const mergedInitialState: typeof initialKycFlowState = {
119+
...initialKycFlowState,
120+
...(initialValues?.currentStep
121+
? { currentStep: initialValues.currentStep }
122+
: {}),
123+
personal: {
124+
...initialKycFlowState.personal,
125+
...initialValues?.personal,
126+
},
127+
address: {
128+
...initialKycFlowState.address,
129+
...initialValues?.address,
130+
},
131+
documents: {
132+
...initialKycFlowState.documents,
133+
...initialValues?.documents,
134+
},
135+
};
136+
137+
const [state, dispatch] = useReducer(kycFlowReducer, mergedInitialState);
90138
const [direction, setDirection] = useState(1);
91139
const [announcement, setAnnouncement] = useState("");
92140
const [stepErrors, setStepErrors] = useState<Record<string, string>>({});

0 commit comments

Comments
 (0)