diff --git a/nextjs_space/app/api/consultation/submit/route.ts b/nextjs_space/app/api/consultation/submit/route.ts index e3e7ce74..89a8e47e 100644 --- a/nextjs_space/app/api/consultation/submit/route.ts +++ b/nextjs_space/app/api/consultation/submit/route.ts @@ -23,6 +23,7 @@ import { import { prisma } from "@/lib/db"; import { buildKycClientPayload } from '@/lib/drgreen/kyc-client-payload'; +import { classifyDrGreenRegistrationError } from '@/lib/drgreen/registration-error'; import crypto from "crypto"; import { z } from "zod"; @@ -34,6 +35,9 @@ import { apiError, apiValidationError } from '@/lib/api-error'; import { checkPolicyGate } from '@/lib/legal/policy-gate'; import { CUSTOMER_TITLES, normaliseCustomerTitle } from '@/lib/customers/titles'; import { CONSENT_SOURCE } from '@/lib/customers/marketing-consent'; +import { REFERRAL_COOKIE_NAME, resolveAffiliateAttribution } from '@/lib/affiliate/affiliate-code'; +import { affiliateCodeField, parseSignUpWithOptionalAffiliateCode } from '@/lib/affiliate/affiliate-code-schema'; +import { clearReferralCookie } from '@/lib/affiliate/referral-cookie'; /** 409 for "that address already belongs to an account you have not proven you own". */ function accountExistsResponse() { @@ -88,6 +92,10 @@ const consultationSchema = z.object({ // BS-303: optional salutation from the fixed list; "" = not chosen. title: z.union([z.enum(CUSTOMER_TITLES), z.literal("")]).optional(), + // BS-A02: optional Dr Green affiliate code (US-A02 format, max 20). A + // malformed one is dropped, never a 400 — parseSignUpWithOptionalAffiliateCode. + affiliateCode: affiliateCodeField, + // SA ID-upload (idMode) — see idDocumentSchema above. idDocument: idDocumentSchema.optional(), @@ -143,7 +151,11 @@ export async function POST(request: NextRequest) { const rawBody = await request.json(); - const parseResult = consultationSchema.safeParse(rawBody); + const { result: parseResult, affiliateCodeIssue } = + parseSignUpWithOptionalAffiliateCode(consultationSchema, rawBody); + if (affiliateCodeIssue) { + logger.info("[Consultation] malformed affiliate code ignored; sign-up continues"); + } if (!parseResult.success) { const firstError = parseResult.error.errors[0]; return apiValidationError( @@ -221,6 +233,11 @@ export async function POST(request: NextRequest) { const customerTitle = normaliseCustomerTitle(body.title); // US-023: consent only on an explicit tick — never inferred. const consented = body.marketingConsent === true; + // BS-A02: 'link' when the submitted code is the remembered bs_ref one, else 'typed'. + const affiliate = resolveAffiliateAttribution( + body.affiliateCode, + request.cookies.get(REFERRAL_COOKIE_NAME)?.value, + ); // A storefront with no published privacy notice tells visitors exactly that // — so taking a consultation here would collect special-category data with @@ -500,6 +517,8 @@ export async function POST(request: NextRequest) { title: customerTitle, marketingConsent: consented, consentSource: registrationSource, + affiliateCode: affiliate?.affiliateCode, + affiliateCodeSource: affiliate?.affiliateCodeSource, shipping: { address1: body.addressLine1, address2: body.addressLine2 || "", @@ -561,6 +580,8 @@ export async function POST(request: NextRequest) { title: customerTitle, marketingConsent: consented, consentSource: registrationSource, + affiliateCode: affiliate?.affiliateCode ?? null, + affiliateCodeSource: affiliate?.affiliateCodeSource ?? null, }); // Submit to Dr. Green API via shared client @@ -645,6 +666,7 @@ export async function POST(request: NextRequest) { data: { drGreenClientId: clientId, tenantId, + ...(affiliate ?? {}), // BS-A03: the code sent with this client, for reference updatedAt: new Date(), }, }); @@ -684,7 +706,7 @@ export async function POST(request: NextRequest) { }, }); - return NextResponse.json({ + const response = NextResponse.json({ success: true, message: "Consultation submitted successfully", questionnaireId: questionnaire.id, @@ -692,6 +714,8 @@ export async function POST(request: NextRequest) { kycLink: kycLink, adminApproval: "PENDING", }); + clearReferralCookie(response); // BS-A02: the landing code has done its job. + return response; } catch (drGreenError: any) { // Message only — the Dr Green error object/body can echo back the // submitted PHI; never log the whole thing. @@ -700,29 +724,9 @@ export async function POST(request: NextRequest) { message: drGreenError instanceof Error ? drGreenError.message : String(drGreenError), }); - // Parse Dr Green error for user-friendly messages - const errorMsg = drGreenError.message || ""; - let userMessage = "Registration failed. Please try again or contact support."; - let statusCode = 500; - let failureCode = "UNKNOWN"; - - if (errorMsg.includes("Phone Number already exists") || errorMsg.includes("phone") && errorMsg.includes("exists")) { - userMessage = "This phone number is already registered. Please use a different phone number or contact support."; - statusCode = 409; - failureCode = "PHONE_EXISTS"; - } else if (errorMsg.includes("email") && errorMsg.includes("exists")) { - userMessage = "This email address is already registered. Please use a different email or try logging in."; - statusCode = 409; - failureCode = "EMAIL_EXISTS"; - } else if (errorMsg.includes("409")) { - userMessage = "An account with these details already exists. Please use different details or contact support."; - statusCode = 409; - failureCode = "CONFLICT"; - } else if (errorMsg.includes("400")) { - userMessage = "Invalid information provided. Please check your details and try again."; - statusCode = 400; - failureCode = "BAD_REQUEST"; - } + // Parse Dr Green error for user-friendly messages (lib/drgreen/registration-error.ts) + const { userMessage, statusCode, failureCode } = + classifyDrGreenRegistrationError(drGreenError.message); // Persist a stable classification, NOT the upstream message. Dr Green // error bodies echo back submitted values (see the logger note above), so diff --git a/nextjs_space/app/store/[slug]/consultation/page.tsx b/nextjs_space/app/store/[slug]/consultation/page.tsx index cea716c5..2d240d30 100644 --- a/nextjs_space/app/store/[slug]/consultation/page.tsx +++ b/nextjs_space/app/store/[slug]/consultation/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { cookies } from "next/headers"; import { ConsultationForm } from "@/components/consultation/consultation-form"; import { notFound } from "next/navigation"; import { generateStoreRouteMetadata } from "@/lib/seo/generate-page-metadata"; @@ -10,6 +11,28 @@ import { getTenantVerificationMode, isSaIdUploadEnabled, } from "@/lib/verification-mode"; +import { + REFERRAL_COOKIE_NAME, + REFERRAL_QUERY_PARAM, + normaliseAffiliateCode, +} from "@/lib/affiliate/affiliate-code"; + +/** + * BS-A02: the referral-code field's initial value. A `?ref=` on this very + * request wins (middleware sets `bs_ref` on this response, so the cookie is + * not on the request yet); otherwise the remembered `bs_ref` cookie. The + * cookie is HttpOnly, so it is read here and handed down, never by the form. + */ +function initialAffiliateCode( + searchParams: Record | undefined, +): string { + const ref = searchParams?.[REFERRAL_QUERY_PARAM]; + return ( + normaliseAffiliateCode(Array.isArray(ref) ? ref[0] : ref) ?? + normaliseAffiliateCode(cookies().get(REFERRAL_COOKIE_NAME)?.value) ?? + "" + ); +} /** * SEO US-007 — nav- and footer-linked (components/navigation.tsx:104, @@ -37,8 +60,10 @@ export async function generateMetadata(): Promise { export default async function ConsultationPage({ params, + searchParams, }: { params: { slug: string }; + searchParams?: Record; }) { const tenant = await getCurrentTenant(); @@ -54,6 +79,7 @@ export default async function ConsultationPage({ // SA ID-upload tenants skip the medical consultation: register + upload an ID. const idMode = isSaIdUploadEnabled() && getTenantVerificationMode(tenant) === "ID_UPLOAD"; + const affiliateCode = initialAffiliateCode(searchParams); return (
Register & verify with your ID - +
@@ -95,7 +125,11 @@ export default async function ConsultationPage({ > Register here - + diff --git a/nextjs_space/app/store/[slug]/register/page.tsx b/nextjs_space/app/store/[slug]/register/page.tsx index 436e855d..4de10341 100644 --- a/nextjs_space/app/store/[slug]/register/page.tsx +++ b/nextjs_space/app/store/[slug]/register/page.tsx @@ -9,7 +9,7 @@ import { getTenantBasePath } from "@/lib/tenant/tenant-utils"; * Registration Redirect * * All customer signups must go through the consultation form for KYC compliance. - * This page redirects to the consultation page. + * This page redirects to the consultation page, query string included. */ export default function RegisterRedirectPage() { const params = useParams(); @@ -19,7 +19,11 @@ export default function RegisterRedirectPage() { useEffect(() => { if (slug) { const basePath = getTenantBasePath(slug); - router.replace(`${basePath}/consultation`); + // BS-A01: keep the query string so a holder's /register?ref=CODE link + // still pre-fills the referral code. Read from window.location (this + // effect only runs in the browser) rather than useSearchParams, which + // would need a Suspense boundary around this page. + router.replace(`${basePath}/consultation${window.location.search}`); } }, [slug, router]); diff --git a/nextjs_space/app/tenant-admin/customers/customers-table.tsx b/nextjs_space/app/tenant-admin/customers/customers-table.tsx index aaecd952..fec9f324 100644 --- a/nextjs_space/app/tenant-admin/customers/customers-table.tsx +++ b/nextjs_space/app/tenant-admin/customers/customers-table.tsx @@ -33,6 +33,10 @@ export interface Customer { createdAt: Date; /** BS-305: when the customer opted in to marketing; null = no consent. */ marketingConsentAt?: Date | null; + /** BS-A03: Dr Green referral code sent with the sign-up (read-only). */ + affiliateCode?: string | null; + /** BS-A03: link | typed. */ + affiliateCodeSource?: string | null; _count: { orders: number; }; @@ -182,6 +186,9 @@ export function CustomersTable({ marketingConsentAt: c.marketingConsentAt ? format(new Date(c.marketingConsentAt), "yyyy-MM-dd HH:mm") : "", + // BS-A03: the Dr Green referral code the customer signed up with. + affiliateCode: c.affiliateCode || "", + affiliateCodeSource: c.affiliateCodeSource || "", })); const csvHeaders = [ @@ -193,6 +200,8 @@ export function CustomersTable({ { key: "createdAt" as const, label: "Joined" }, { key: "marketingConsent" as const, label: "Marketing consent" }, { key: "marketingConsentAt" as const, label: "Consent given" }, + { key: "affiliateCode" as const, label: "Referral code" }, + { key: "affiliateCodeSource" as const, label: "Referral source" }, ]; await exportToCSV( @@ -364,6 +373,7 @@ export function CustomersTable({ /> Status Marketing + Referral