diff --git a/docs/guides/SUPER_ADMIN_MANUAL.md b/docs/guides/SUPER_ADMIN_MANUAL.md index 536f3c11..24a99947 100644 --- a/docs/guides/SUPER_ADMIN_MANUAL.md +++ b/docs/guides/SUPER_ADMIN_MANUAL.md @@ -199,6 +199,16 @@ Upon approval, the system automatically: > **Note:** `tenants.nftTokenId` is an optional legacy field on the tenant record. It does not gate access or activation and can be left empty. +### South African ID-upload tenants — Dr Green key checklist + +Applies to every tenant whose verification mode is **ID upload** (South Africa only). Dr Green's identity emails — "we've received your ID document" and the rejection email with its re-upload link — build the link back to the storefront from the partner's branding website or, failing that, from the **first allowed return host on the tenant's Dr Green API key**, plus `/dashboard`. A key with no host sends the customer to the Dr Green app instead of the store. + +1. The tenant admin adds the Dr Green API key and secret under **Tenant Admin → Settings**. +2. On the Dr Green dApp **Keys** page, the KEY holder adds the storefront host to that key's allowed return hosts: `.budstacks.io`, and the custom domain as well once it is live. Wildcards (`*.example`) are ignored by the link resolver, so list the exact host. +3. Confirm on staging before go-live: reject a test customer's ID in the Dr Green admin and check the email link lands on `https:///dashboard`, where the existing re-upload card is shown. + +The link resolution is Dr Green Phase 2 (US-208); until that release is on production the rejection email still points at the partner branding website or the Dr Green app. See `tasks/prd-drgreen-phase-alignment-2026-09.md` (BS-205). + --- ## Analytics & Reporting diff --git a/nextjs_space/app/actions/kyc-check.ts b/nextjs_space/app/actions/kyc-check.ts index 6a23f656..31cc46e3 100644 --- a/nextjs_space/app/actions/kyc-check.ts +++ b/nextjs_space/app/actions/kyc-check.ts @@ -5,6 +5,7 @@ import { prisma } from "@/lib/db"; import { getTenantDrGreenConfig } from "@/lib/tenant/tenant-config"; import { fetchClient, fetchClientByEmail } from "@/lib/drgreen/doctor-green-api"; import { canonicalAdminApproval } from "@/lib/drgreen/approval-status"; +import { customerSafeIdDocumentError } from "@/lib/verification/id-document-errors"; import { logger } from "@/lib/logger"; export type KycStatus = { @@ -21,8 +22,22 @@ export type KycStatus = { // dashboard's switch-to-ID offer for stuck legacy AML clients on // ID-upload tenants. verificationType?: 'KYC' | 'ID' | null; + // BS-204 — why the last upload was recorded UPLOAD_FAILED, but only when + // the stored reason is customer-facing copy (the SA ID message). Present + // only in that case; raw upstream errors never leave the server. + idDocumentError?: string; }; +// The `{ idDocumentError }` fragment for an UPLOAD_FAILED flag, or nothing — +// so every other state keeps exactly the object shape it had. +function uploadFailureReason( + status: string | null | undefined, + stored: string | null | undefined, +): { idDocumentError: string } | Record { + const safe = status === "UPLOAD_FAILED" ? customerSafeIdDocumentError(stored) : null; + return safe ? { idDocumentError: safe } : {}; +} + // Narrow Dr Green's string field to the two values the UI branches on; // anything unexpected reads as null so no CTA renders off a bad value. function narrowVerificationType(value: unknown): 'KYC' | 'ID' | null { @@ -64,7 +79,7 @@ export async function checkUserKycStatus(): Promise { tenantId: tenantId, email: { equals: clerkUser.email, mode: 'insensitive' } }, - select: { id: true, idDocumentStatus: true } + select: { id: true, idDocumentStatus: true, idDocumentError: true } }); if (questionnaire) { @@ -73,6 +88,10 @@ export async function checkUserKycStatus(): Promise { kycVerified: false, status: "PENDING", idDocumentStatus: questionnaire.idDocumentStatus ?? null, + ...uploadFailureReason( + questionnaire.idDocumentStatus, + questionnaire.idDocumentError, + ), }; } @@ -108,9 +127,15 @@ export async function checkUserKycStatus(): Promise { { isKycVerified: 'desc' }, { createdAt: 'desc' } ], - select: { isKycVerified: true, adminApproval: true, idDocumentStatus: true } + select: { + isKycVerified: true, + adminApproval: true, + idDocumentStatus: true, + idDocumentError: true, + } }); const idDocumentStatus = questionnaire?.idDocumentStatus ?? null; + const idDocumentError = questionnaire?.idDocumentError ?? null; // Fetch Config and check Dr Green API try { @@ -261,6 +286,7 @@ export async function checkUserKycStatus(): Promise { status: "REJECTED", message: client.rejectionNote || undefined, idDocumentStatus, + ...uploadFailureReason(idDocumentStatus, idDocumentError), verificationType: narrowVerificationType(client.verificationType), }; } @@ -275,6 +301,7 @@ export async function checkUserKycStatus(): Promise { kycVerified: isVerified, status, idDocumentStatus: isVerified ? null : idDocumentStatus, + ...uploadFailureReason(isVerified ? null : idDocumentStatus, idDocumentError), verificationType: narrowVerificationType(client.verificationType), }; } catch (configOrApiError) { @@ -286,6 +313,7 @@ export async function checkUserKycStatus(): Promise { status: "API_ERROR", message: `Dr Green API error: ${errMsg}`, idDocumentStatus, + ...uploadFailureReason(idDocumentStatus, idDocumentError), }; } diff --git a/nextjs_space/app/api/consultation/submit/route.ts b/nextjs_space/app/api/consultation/submit/route.ts index 9e5b5301..e3e7ce74 100644 --- a/nextjs_space/app/api/consultation/submit/route.ts +++ b/nextjs_space/app/api/consultation/submit/route.ts @@ -11,21 +11,29 @@ import { createSaIdClient, uploadIdentityDocument } from "@/lib/drgreen-identity import { recordIdDocumentOutcome } from "@/lib/verification/id-document-status"; import { getTenantVerificationMode, + isSaIdEligibleTenant, isSaIdUploadEnabled, } from "@/lib/verification-mode"; +import { + documentNumberToForward, + hasSaIdInvalidIssue, + saIdDocumentRefinement, + saIdInvalidBody, +} from "@/lib/verification/sa-id-schema"; import { prisma } from "@/lib/db"; -import { mapMedicalConditionsForDrGreen } from '@/lib/drgreen/dr-green-mapping'; +import { buildKycClientPayload } from '@/lib/drgreen/kyc-client-payload'; import crypto from "crypto"; import { z } from "zod"; -import { toAlpha3 as convertToAlpha3CountryCode } from '@/lib/country-codes'; import { checkRateLimit } from '@/lib/security/rate-limit'; import { getTenantFromRequest } from '@/lib/tenant/tenant'; import { resolveTenant } from '@/lib/tenant/tenant-resolver'; import { logger } from '@/lib/logger'; 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'; /** 409 for "that address already belongs to an account you have not proven you own". */ function accountExistsResponse() { @@ -37,6 +45,21 @@ function accountExistsResponse() { }); } +// SA ID-upload (idMode): document sent inline with registration so the +// account + Dr Green client + document are created in one action. +const idDocumentSchema = z.object({ + fileBase64: z.string().min(1), + mimeType: z.string().max(100), + documentType: z.enum(["ID", "PASSPORT", "DRIVING_LICENCE"]), + documentNumber: z.string().trim().min(1).max(100), +}); + +// BS-202: the South African ID rules need the tenant, which is resolved from +// the request AFTER the body is parsed — so the refinement is applied to the +// already-validated `idDocument` object once the tenant is known (below). +const idDocumentSchemaFor = (enforceSaId: boolean) => + idDocumentSchema.superRefine(saIdDocumentRefinement(enforceSaId)); + // SECURITY (C1, C13): Strict whitelist schema — no `.passthrough()`. Every // field that lands in the database or is forwarded to Dr. Green must be // declared here and length-capped. The tenant is resolved server-side from @@ -62,16 +85,11 @@ const consultationSchema = z.object({ // and UNTICKED by default — absent or false records NO consent. marketingConsent: z.boolean().optional(), - // SA ID-upload (idMode): document sent inline with registration so the - // account + Dr Green client + document are created in one action. - idDocument: z - .object({ - fileBase64: z.string().min(1), - mimeType: z.string().max(100), - documentType: z.enum(["ID", "PASSPORT", "DRIVING_LICENCE"]), - documentNumber: z.string().trim().min(1).max(100), - }) - .optional(), + // BS-303: optional salutation from the fixed list; "" = not chosen. + title: z.union([z.enum(CUSTOMER_TITLES), z.literal("")]).optional(), + + // SA ID-upload (idMode) — see idDocumentSchema above. + idDocument: idDocumentSchema.optional(), // Shipping address addressLine1: z.string().max(300).optional().default(""), @@ -171,6 +189,39 @@ export async function POST(request: NextRequest) { } const tenantId = tenant.id; + // BS-202: refuse an impossible South African ID number before ANY account, + // questionnaire or Dr Green client exists — the customer corrects the + // number and resubmits with nothing to clean up. South African tenants and + // document type ID only; the copy is the one Dr Green and WordPress use. + let idDocumentNumber: string | undefined; + if (body.idDocument) { + const enforceSaId = isSaIdEligibleTenant(tenant); + const idDoc = idDocumentSchemaFor(enforceSaId).safeParse(body.idDocument); + if (!idDoc.success) { + if (hasSaIdInvalidIssue(idDoc.error)) { + return NextResponse.json(saIdInvalidBody(), { status: 400 }); + } + return apiValidationError( + "Invalid document type or number", + "POST /api/consultation/submit", + ); + } + idDocumentNumber = documentNumberToForward(idDoc.data, enforceSaId); + } + + // SA ID-upload path creates the client via verificationType "ID" (no + // medical questionnaire). Otherwise the standard KYC/First-AML payload. + // Phase 3 (BS-301..303): consent and title are attributed to that path. + const idMode = + isSaIdUploadEnabled() && + getTenantVerificationMode(tenant) === "ID_UPLOAD"; + const registrationSource = idMode + ? CONSENT_SOURCE.ID_UPLOAD + : CONSENT_SOURCE.CONSULTATION; + const customerTitle = normaliseCustomerTitle(body.title); + // US-023: consent only on an explicit tick — never inferred. + const consented = body.marketingConsent === true; + // A storefront with no published privacy notice tells visitors exactly that // — so taking a consultation here would collect special-category data with // no Art. 13 notice at all. Checked before ANY account or record is created. @@ -292,10 +343,12 @@ export async function POST(request: NextRequest) { firstName: body.firstName, lastName: body.lastName, phone: [body.phoneCode, body.phoneNumber].filter(Boolean).join(" ").trim() || null, + ...(customerTitle ? { title: customerTitle } : {}), // US-023: a tick at signup grants consent; unticked NEVER clears // an earlier grant — withdrawal is unsubscribe/admin-only. - ...(body.marketingConsent === true && { + ...(consented && { marketingConsentAt: new Date(), + marketingConsentSource: registrationSource, }), updatedAt: new Date(), }, @@ -321,8 +374,10 @@ export async function POST(request: NextRequest) { phone: [body.phoneCode, body.phoneNumber].filter(Boolean).join(" ").trim() || null, role: "PATIENT", tenantId, + title: customerTitle, // US-023: consent only on an explicit tick — never inferred. - marketingConsentAt: body.marketingConsent === true ? new Date() : null, + marketingConsentAt: consented ? new Date() : null, + marketingConsentSource: consented ? registrationSource : null, updatedAt: new Date(), }, }); @@ -360,8 +415,9 @@ export async function POST(request: NextRequest) { tenantId, role: "PATIENT", // US-023: the webhook race must not lose an explicit tick. - ...(body.marketingConsent === true && { + ...(consented && { marketingConsentAt: new Date(), + marketingConsentSource: registrationSource, }), updatedAt: new Date(), }, @@ -430,12 +486,6 @@ export async function POST(request: NextRequest) { const { apiKey, secretKey, apiUrl } = await getTenantDrGreenConfig(tenantId); logger.debug("[Consultation] Dr Green credentials loaded", { tenantId }); - // SA ID-upload path creates the client via verificationType "ID" (no - // medical questionnaire). Otherwise the standard KYC/First-AML payload. - const idMode = - isSaIdUploadEnabled() && - getTenantVerificationMode(tenant) === "ID_UPLOAD"; - let clientId: string | undefined; let kycLink: string | null = null; @@ -447,6 +497,9 @@ export async function POST(request: NextRequest) { phoneCode: body.phoneCode.replace(/[^\+\d]/g, ""), phoneCountryCode: body.countryCode, contactNumber: body.phoneNumber.replace(/\D/g, ""), + title: customerTitle, + marketingConsent: consented, + consentSource: registrationSource, shipping: { address1: body.addressLine1, address2: body.addressLine2 || "", @@ -471,7 +524,8 @@ export async function POST(request: NextRequest) { await uploadIdentityDocument({ clientId, documentType: body.idDocument.documentType, - documentNumber: body.idDocument.documentNumber, + // Space-stripped for an SA ID (BS-202); as typed otherwise. + documentNumber: idDocumentNumber ?? body.idDocument.documentNumber, file: Buffer.from(body.idDocument.fileBase64, "base64"), mimeType: body.idDocument.mimeType, config: { apiKey, secretKey }, @@ -501,102 +555,13 @@ export async function POST(request: NextRequest) { } } } else { - // Format date for Dr. Green API (YYYY-MM-DD) - const dobFormatted = body.dateOfBirth - ? new Date(body.dateOfBirth).toISOString().split("T")[0] - : new Date().toISOString().split("T")[0]; - - // Prepare Dr. Green API payload - const drGreenPayload = { - firstName: body.firstName, - lastName: body.lastName, - email: body.email.toLowerCase(), // Dr Green requires lowercase - phoneCode: body.phoneCode.replace(/[^\+\d]/g, ""), // e.g. "+351" - phoneCountryCode: body.countryCode, // e.g. "PT" (2-letter ISO code) - contactNumber: body.phoneNumber.replace(/\D/g, ""), // e.g. "7970433737" (digits only, NO prefix) - - shipping: { - address1: body.addressLine1, - address2: body.addressLine2 || '', - landmark: '', - city: body.city, - state: body.state, - postalCode: body.postalCode, - country: body.country, - countryCode: convertToAlpha3CountryCode(body.countryCode), // Convert PT → PRT - }, - - ...(body.businessType && body.businessName - ? { - clientBusiness: { - businessType: body.businessType, - name: body.businessName, - address1: body.businessAddress1 || "", - address2: body.businessAddress2 || "", - city: body.businessCity || "", - state: body.businessState || "", - postalCode: body.businessPostalCode || "", - country: body.businessCountry || "", - countryCode: body.businessCountryCode || "", - }, - } - : {}), - - medicalRecord: { - dob: dobFormatted, - gender: body.gender, - medicalConditions: mapMedicalConditionsForDrGreen( - body.medicalConditions || [], - ), - // Only include otherMedicalCondition if we have conditions that map to 'other_medical_condition' - ...(body.medicalConditions?.includes("lupus") || - body.medicalConditions?.includes("asthma") || - body.medicalConditions?.includes("glaucoma") || - body.medicalConditions?.includes("other_medical_condition") || - body.medicalConditions?.includes("other") || - body.otherCondition - ? { - otherMedicalCondition: - body.medicalConditions - ?.filter((c: string) => - [ - "lupus", - "asthma", - "glaucoma", - "other_medical_condition", - "other", - ].includes(c), - ) - .map((c: string) => c.charAt(0).toUpperCase() + c.slice(1)) - .join(", ") || - body.otherCondition || - "Other medical condition", - } - : {}), - otherMedicalTreatments: "", - prescribedSupplements: body.prescribedSupplements || "", - - // Medical History - Dr Green uses specific field names - medicalHistory0: body.hasHeartProblems, - medicalHistory1: body.hasCancerTreatment, - medicalHistory2: body.hasImmunosuppressants, - medicalHistory3: body.hasLiverDisease, - medicalHistory4: body.hasPsychiatricHistory, - medicalHistory5: body.hasPsychiatricHistory ? ["depression"] : ["none"], - medicalHistory6: false, // Suicidal history - medicalHistory7: ["none"], // Family history - medicalHistory7Relation: "none", - medicalHistory8: body.hasDrugServices, - medicalHistory9: body.hasAlcoholAbuse, - medicalHistory10: body.hasDrugServices, - medicalHistory11: body.alcoholUnitsPerWeek || "0", - medicalHistory12: body.cannabisReducesMeds, - medicalHistory13: body.cannabisFrequency || "never", - medicalHistory14: body.cannabisFrequency && body.cannabisFrequency !== "never" ? ["vaporizing"] : ["never"], - medicalHistory15: body.cannabisAmountPerDay || "", - medicalHistory16: false, // cannabisReaction - }, - }; + // Prepare Dr. Green API payload — lib/drgreen/kyc-client-payload.ts + const drGreenPayload = buildKycClientPayload({ + ...body, + title: customerTitle, + marketingConsent: consented, + consentSource: registrationSource, + }); // Submit to Dr. Green API via shared client const drGreenResponse = await callDrGreenAPI('/dapp/clients', { diff --git a/nextjs_space/app/api/shop/register/route.ts b/nextjs_space/app/api/shop/register/route.ts index 253dfa79..0b38061e 100644 --- a/nextjs_space/app/api/shop/register/route.ts +++ b/nextjs_space/app/api/shop/register/route.ts @@ -5,6 +5,8 @@ import { prisma } from '@/lib/db'; import { getCurrentTenant } from '@/lib/tenant/tenant'; import { getTenantDrGreenConfig } from '@/lib/tenant/tenant-config'; import { apiError, apiValidationError } from '@/lib/api-error'; +import { normaliseCustomerTitle } from '@/lib/customers/titles'; +import { CONSENT_SOURCE } from '@/lib/customers/marketing-consent'; export const POST = withAuth(async (req, { user }) => { try { @@ -28,13 +30,18 @@ export const POST = withAuth(async (req, { user }) => { } const body = await req.json(); - const { personal, address, medicalRecord } = body; + const { personal, address, medicalRecord, marketingConsent, title } = body; // Validate required fields if (!personal || !address || !medicalRecord) { return apiValidationError("Missing required fields", "POST /api/shop/register"); } + // Phase 3 (BS-301..303): salutation from the fixed list and marketing + // consent — only an explicit true counts; anything else records nothing. + const customerTitle = normaliseCustomerTitle(title ?? personal.title); + const consented = marketingConsent === true; + // Get current tenant for Dr. Green API keys const tenant = await getCurrentTenant(); @@ -121,6 +128,9 @@ export const POST = withAuth(async (req, { user }) => { phoneCode: phoneCode, phoneCountryCode: tenant?.countryCode || "ZA", contactNumber: contactNumber, + title: customerTitle ?? undefined, + marketingConsent: consented, + consentSource: CONSENT_SOURCE.SHOP_REGISTER, shipping: { address1: address.street, city: address.city, @@ -156,6 +166,15 @@ export const POST = withAuth(async (req, { user }) => { // Phone was collected + validated above but previously only sent to // Dr Green — persist it locally so Customers detail/export show it. phone: `${phoneCode} ${contactNumber}`.trim(), + ...(customerTitle ? { title: customerTitle } : {}), + // US-023 rule: a tick grants consent; unticked never clears an + // earlier grant (withdrawal is the customer's own settings toggle). + ...(consented + ? { + marketingConsentAt: new Date(), + marketingConsentSource: CONSENT_SOURCE.SHOP_REGISTER, + } + : {}), // The Dr Green client id was previously returned to the browser but // never persisted, leaving these customers unreachable by webhooks // and status sync — permanently "pending" on every admin surface. diff --git a/nextjs_space/app/api/store/[slug]/consent/route.ts b/nextjs_space/app/api/store/[slug]/consent/route.ts new file mode 100644 index 00000000..61bd68c0 --- /dev/null +++ b/nextjs_space/app/api/store/[slug]/consent/route.ts @@ -0,0 +1,204 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { withAuth } from "@/lib/api-auth"; +import { prisma } from "@/lib/db"; +import { getCurrentTenant } from "@/lib/tenant/tenant"; +import { getTenantDrGreenConfig } from "@/lib/tenant/tenant-config"; +import { apiError } from "@/lib/api-error"; +import { parseSlug } from "@/lib/validation/parse-uuid"; +import { parseJsonBody } from "@/lib/validation/body"; +import { createAuditLog, AUDIT_ACTIONS, getClientInfo } from "@/lib/audit-log"; +import { + mapDrGreenApiError, + updateClientMarketingConsent, +} from "@/lib/drgreen-identity"; +import { CONSENT_SOURCE } from "@/lib/customers/marketing-consent"; +import { logger } from "@/lib/logger"; + +// Node runtime: the Dr Green client signs requests with node:crypto. +export const runtime = "nodejs"; + +const ROUTE = "store.consent"; + +const consentBodySchema = z.object({ consent: z.boolean() }).strict(); + +const USER_SELECT = { + id: true, + email: true, + drGreenClientId: true, + marketingConsentAt: true, +} as const; + +interface ConsentState { + marketingConsent: boolean; + marketingConsentAt: string | null; +} + +function consentState(marketingConsentAt: Date | null): ConsentState { + return { + marketingConsent: marketingConsentAt !== null, + marketingConsentAt: marketingConsentAt?.toISOString() ?? null, + }; +} + +/** + * Customer-safe explanation when Dr Green did not take the change. 404 is + * either "no such client" or, until Phase 3 is on production, "no such + * route"; both read the same to the customer. + */ +function forwardWarning(error: unknown): string { + const mapped = mapDrGreenApiError(error); + if (mapped?.status === 404) { + return "Your choice is saved for this store. Dr Green's record of your account could not be updated yet."; + } + if (mapped?.status === 409 || mapped?.status === 400) { + return mapped.message + ? `Your choice is saved for this store. Dr Green replied: ${mapped.message}` + : "Your choice is saved for this store, but Dr Green did not accept the change."; + } + return "Your choice is saved for this store. We could not reach Dr Green to update its record; we will keep your choice here."; +} + +/** + * GET /api/store/[slug]/consent — the signed-in customer's own marketing + * consent state, read from the column every BudStacks send is gated on. + */ +export const GET = withAuth(async (_request, { user }, { slug }) => { + try { + parseSlug(slug); + if (!user.email) { + return NextResponse.json({ error: "Email not found" }, { status: 401 }); + } + const tenant = await getCurrentTenant(); + if (!tenant) { + return NextResponse.json({ error: "Store not found" }, { status: 404 }); + } + const dbUser = await prisma.users.findFirst({ + where: { email: user.email }, + select: { marketingConsentAt: true }, + }); + if (!dbUser) { + return NextResponse.json({ error: "Account not found" }, { status: 404 }); + } + return NextResponse.json(consentState(dbUser.marketingConsentAt)); + } catch (error) { + return apiError(error, { + route: `GET ${ROUTE}`, + status: 500, + safeMessage: "Could not load your marketing preference.", + }); + } +}); + +/** + * PATCH /api/store/[slug]/consent — BS-304. The customer gives or withdraws + * marketing consent from their settings page. + * + * ORDER MATTERS. The local column is written FIRST and unconditionally: it is + * the consent test for every campaign BudStacks sends (the tenant's own POPIA + * exposure), so a withdrawal must never depend on a partner API answering. + * Dr Green (`PATCH /dapp/clients/:id/marketing-consent`, Phase 3 US-302) is + * then updated best-effort so the KEY holder's export agrees; when it cannot + * be — the route is not on production yet, the client is unknown, or Dr Green + * refuses — the response still succeeds and says so in `warning`, with the + * status logged. The audit row is written either way: who flipped it, when, + * and from where. + */ +export const PATCH = withAuth(async (request, { user }, { slug }) => { + try { + parseSlug(slug); + if (!user.email) { + return NextResponse.json({ error: "Email not found" }, { status: 401 }); + } + const tenant = await getCurrentTenant(); + if (!tenant) { + return NextResponse.json({ error: "Store not found" }, { status: 404 }); + } + + const { consent } = await parseJsonBody(request, consentBodySchema); + + const dbUser = await prisma.users.findFirst({ + where: { email: user.email }, + select: USER_SELECT, + }); + if (!dbUser) { + return NextResponse.json({ error: "Account not found" }, { status: 404 }); + } + + const now = new Date(); + const marketingConsentAt = consent ? now : null; + await prisma.users.update({ + where: { id: dbUser.id }, + data: { + marketingConsentAt, + marketingConsentSource: CONSENT_SOURCE.STORE_SETTINGS, + updatedAt: now, + }, + }); + + const { ipAddress, userAgent } = getClientInfo(request.headers); + await createAuditLog({ + action: consent + ? AUDIT_ACTIONS.CUSTOMER_MARKETING_CONSENT_GRANTED + : AUDIT_ACTIONS.CUSTOMER_MARKETING_CONSENT_REVOKED, + entityType: "User", + entityId: dbUser.id, + userId: dbUser.id, + userEmail: dbUser.email, + tenantId: tenant.id, + metadata: { + source: CONSENT_SOURCE.STORE_SETTINGS, + previousConsentAt: dbUser.marketingConsentAt?.toISOString() ?? null, + newConsentAt: marketingConsentAt?.toISOString() ?? null, + }, + ipAddress, + userAgent, + }); + + let forwarded = false; + let warning: string | undefined; + if (dbUser.drGreenClientId) { + try { + const config = await getTenantDrGreenConfig(tenant.id); + await updateClientMarketingConsent({ + clientId: dbUser.drGreenClientId, + consent, + consentSource: CONSENT_SOURCE.STORE_SETTINGS, + config: { apiKey: config.apiKey, secretKey: config.secretKey }, + baseUrl: config.apiUrl, + }); + forwarded = true; + } catch (forwardError) { + const mapped = mapDrGreenApiError(forwardError); + logger.warn("[Consent] Dr Green did not take the consent change", { + userId: dbUser.id, + drGreenClientId: dbUser.drGreenClientId, + consent, + status: mapped?.status ?? null, + error: + forwardError instanceof Error + ? forwardError.message + : String(forwardError), + }); + warning = forwardWarning(forwardError); + } + } else { + logger.info("[Consent] no Dr Green client on this account; local only", { + userId: dbUser.id, + }); + } + + return NextResponse.json({ + ...consentState(marketingConsentAt), + forwarded, + ...(warning ? { warning } : {}), + }); + } catch (error) { + return apiError(error, { + route: `PATCH ${ROUTE}`, + status: 500, + safeMessage: "Could not update your marketing preference. Please try again.", + }); + } +}); diff --git a/nextjs_space/app/api/store/[slug]/verify/id-document/route.ts b/nextjs_space/app/api/store/[slug]/verify/id-document/route.ts index 53773e12..234f953b 100644 --- a/nextjs_space/app/api/store/[slug]/verify/id-document/route.ts +++ b/nextjs_space/app/api/store/[slug]/verify/id-document/route.ts @@ -15,19 +15,35 @@ import { } from "@/lib/drgreen-identity"; import { getTenantVerificationMode, + isSaIdEligibleTenant, isSaIdUploadEnabled, } from "@/lib/verification-mode"; import { recordIdDocumentOutcome } from "@/lib/verification/id-document-status"; +import { SA_ID_INVALID_MESSAGE } from "@/lib/verification/sa-id"; +import { + documentNumberToForward, + hasSaIdInvalidIssue, + isSaIdInvalidUpstreamError, + saIdDocumentRefinement, + saIdInvalidBody, +} from "@/lib/verification/sa-id-schema"; // Node runtime is REQUIRED: drgreen-identity signs over a Node Buffer, whose // JSON.stringify form differs from a Uint8Array/Blob. Edge would break signing. export const runtime = "nodejs"; -const metaSchema = z.object({ +const baseMetaSchema = z.object({ documentType: z.enum(["ID", "PASSPORT", "DRIVING_LICENCE"]), documentNumber: z.string().trim().min(1).max(100), }); +// BS-202: on a South African tenant an ID-type upload must carry a number that +// can exist — checked here, before anything is sent, with the copy Dr Green +// and the WordPress plugin use. Passport and driving-licence numbers, and +// every non-SA tenant, are untouched. +const metaSchemaFor = (enforceSaId: boolean) => + baseMetaSchema.superRefine(saIdDocumentRefinement(enforceSaId)); + /** * Forward a customer's ID document to Dr Green for the SA ID-upload path. * Budstacks is a pure pass-through: it validates, forwards, and stores NOTHING @@ -100,11 +116,19 @@ export const POST = withAuth(async (request, { user }, { slug }) => { ); } - const meta = metaSchema.safeParse({ + // The tenant's country decides whether the SA ID rules apply — never the + // customer's address (PRD §6). The gate above already limits this route to + // ZA tenants; the flag keeps the rule explicit and testable. + const enforceSaId = isSaIdEligibleTenant(tenant); + const meta = metaSchemaFor(enforceSaId).safeParse({ documentType: form.get("documentType"), documentNumber: form.get("documentNumber"), }); if (!meta.success) { + if (hasSaIdInvalidIssue(meta.error)) { + // Nothing was attempted upstream, so no UPLOAD_FAILED outcome either. + return NextResponse.json(saIdInvalidBody(), { status: 400 }); + } return NextResponse.json( { error: "Invalid document type or number" }, { status: 400 }, @@ -118,13 +142,26 @@ export const POST = withAuth(async (request, { user }, { slug }) => { await uploadIdentityDocument({ clientId: dbUser.drGreenClientId, documentType: meta.data.documentType as IdentityDocumentType, - documentNumber: meta.data.documentNumber, + documentNumber: documentNumberToForward(meta.data, enforceSaId), file: fileBuffer, mimeType, config: { apiKey: config.apiKey, secretKey: config.secretKey }, baseUrl: config.apiUrl, }); } catch (uploadError) { + // BS-204: Dr Green's own strict check refused the number (only when the + // two validators drift — ours ran first). Nothing was stored upstream; + // record the customer-facing reason so the dashboard shows it beside the + // re-upload card, and answer exactly as the local check would have. + if (isSaIdInvalidUpstreamError(uploadError)) { + await recordIdDocumentOutcome({ + tenantId: tenant.id, + email, + outcome: "UPLOAD_FAILED", + error: new Error(SA_ID_INVALID_MESSAGE), + }); + return NextResponse.json(saIdInvalidBody(), { status: 400 }); + } // PRD-220 Part B: keep the outcome flag truthful so the dashboard CTA // and the tenant-admin badge stay in sync with reality. await recordIdDocumentOutcome({ diff --git a/nextjs_space/app/store/[slug]/consultation/page.tsx b/nextjs_space/app/store/[slug]/consultation/page.tsx index 107ed4bc..cea716c5 100644 --- a/nextjs_space/app/store/[slug]/consultation/page.tsx +++ b/nextjs_space/app/store/[slug]/consultation/page.tsx @@ -74,7 +74,7 @@ export default async function ConsultationPage({ > Register & verify with your ID - + @@ -95,7 +95,7 @@ export default async function ConsultationPage({ > Register here - + diff --git a/nextjs_space/app/store/[slug]/dashboard/page.tsx b/nextjs_space/app/store/[slug]/dashboard/page.tsx index f38c87e2..249cf523 100644 --- a/nextjs_space/app/store/[slug]/dashboard/page.tsx +++ b/nextjs_space/app/store/[slug]/dashboard/page.tsx @@ -181,6 +181,14 @@ export default function DashboardPage() { Your account was created, but the ID upload didn't go through — verification can't start until we have it. Please upload it again below.

+ {/* BS-204: the reason, when it is copy written for customers + (an ID number Dr Green refused); raw errors are never shown. */} + {kycStatus?.idDocumentError && ( +

+ Reason:{" "} + {kycStatus.idDocumentError} +

+ )} checkUserKycStatus().then(setKycStatus)} diff --git a/nextjs_space/app/store/[slug]/settings/page.tsx b/nextjs_space/app/store/[slug]/settings/page.tsx index c37eacb1..2cf58e12 100644 --- a/nextjs_space/app/store/[slug]/settings/page.tsx +++ b/nextjs_space/app/store/[slug]/settings/page.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; import Link from "next/link"; import { toast } from "@/components/ui/sonner"; import { getTenantBasePath } from "@/lib/tenant/tenant-utils"; @@ -28,6 +29,49 @@ export default function SettingsPage() { checkUserKycStatus().then(setKycStatus); }, []); + // BS-304: marketing consent — read from and written to the column every + // BudStacks campaign is gated on; the change is forwarded to Dr Green. + type ConsentState = { marketingConsent: boolean; marketingConsentAt: string | null }; + const [consent, setConsent] = useState(null); + const [consentSaving, setConsentSaving] = useState(false); + + useEffect(() => { + if (!slug) return; + fetch(`/api/store/${slug}/consent`) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => setConsent(data ?? null)) + .catch(() => setConsent(null)); + }, [slug]); + + const handleConsentChange = async (next: boolean) => { + setConsentSaving(true); + try { + const response = await fetch(`/api/store/${slug}/consent`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ consent: next }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data?.error || "Could not update your preference"); + } + setConsent({ + marketingConsent: data.marketingConsent === true, + marketingConsentAt: data.marketingConsentAt ?? null, + }); + toast.success( + next + ? "You'll hear about products and offers from this store." + : "You won't receive marketing from this store.", + ); + if (data.warning) toast.warning(data.warning); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Could not update your preference"); + } finally { + setConsentSaving(false); + } + }; + const [formData, setFormData] = useState({ firstName: "", lastName: "", @@ -402,6 +446,37 @@ export default function SettingsPage() { + {/* Marketing preferences — BS-304 */} +
+

+ + Marketing emails and SMS +

+

+ Choose whether we may tell you about products and offers. Emails about + your orders and verification are always sent. +

+
+
+ +

+ {consent === null + ? "Loading your preference…" + : consent.marketingConsent + ? `On${consent.marketingConsentAt ? ` since ${new Date(consent.marketingConsentAt).toLocaleDateString()}` : ""}` + : "Off — you can turn this on at any time"} +

+
+ +
+
+ {/* Back to Dashboard */}
diff --git a/nextjs_space/app/tenant-admin/customers/customers-table.tsx b/nextjs_space/app/tenant-admin/customers/customers-table.tsx index 5a029ae2..aaecd952 100644 --- a/nextjs_space/app/tenant-admin/customers/customers-table.tsx +++ b/nextjs_space/app/tenant-admin/customers/customers-table.tsx @@ -31,6 +31,8 @@ export interface Customer { name: string | null; phone?: string | null; createdAt: Date; + /** BS-305: when the customer opted in to marketing; null = no consent. */ + marketingConsentAt?: Date | null; _count: { orders: number; }; @@ -46,6 +48,8 @@ interface CustomersTableProps { availableTags?: string[]; /** Tenant-wide approval breakdown (unfiltered, matches the stat cards). */ statusCounts?: Record; + /** BS-305: tenant-wide count of customers who opted in to marketing. */ + consentedCount?: number; /** Last "Refresh from Dr Green" run (ISO), or null if never refreshed. */ lastSyncedAt?: string | null; /** False for a cross-tenant super-admin view — nothing to refresh. */ @@ -57,17 +61,24 @@ function StatusPill({ status }: { status: CustomerVerificationStatus }) { return {display.label}; } -/** Filter shape for useTableState — `tag` rides the URL as ?tag=. */ -type CustomerFilters = { tag: string } & Record; +/** Filter shape for useTableState — `tag` rides the URL as ?tag=, + * `consent` as ?consent=yes (BS-305, "Consented only"). */ +type CustomerFilters = { tag: string; consent: string } & Record; /** Module-level so the object identity is stable across renders. */ -const DEFAULT_FILTERS: CustomerFilters = { tag: "" }; +const DEFAULT_FILTERS: CustomerFilters = { tag: "", consent: "" }; + +const CONSENT_FILTER_OPTIONS = [ + { value: "", label: "All customers" }, + { value: "yes", label: "Consented only" }, +]; export function CustomersTable({ customers, totalCount, availableTags = [], statusCounts, + consentedCount, lastSyncedAt, canRefresh = false, }: CustomersTableProps) { @@ -103,8 +114,10 @@ export function CustomersTable({ const tagFilter = normalizeTag(filters.tag || ""); const hasTagFilter = tagFilter.length > 0; + const consentFilter = filters.consent === "yes"; + const hasSearchQuery = search.trim().length > 0; - const hasActiveFilters = hasSearchQuery || hasTagFilter; + const hasActiveFilters = hasSearchQuery || hasTagFilter || consentFilter; const noResults = totalCount === 0 && hasActiveFilters; const tagOptions = useMemo(() => { @@ -119,6 +132,11 @@ export function CustomersTable({ }, [availableTags, hasTagFilter, tagFilter]); const emptyDescription = useMemo(() => { + if (consentFilter) { + return hasSearchQuery || hasTagFilter + ? "No customers matching those filters have opted in to marketing." + : "No customers have opted in to marketing yet."; + } if (hasSearchQuery && hasTagFilter) { return `No customers tagged "${tagFilter}" match "${search}". Try different filters.`; } @@ -129,13 +147,16 @@ export function CustomersTable({ return `No customers found matching "${search}". Try a different search term.`; } return "No customers yet. Share your store URL to get started."; - }, [hasSearchQuery, hasTagFilter, search, tagFilter]); + }, [consentFilter, hasSearchQuery, hasTagFilter, search, tagFilter]); const handleClearFilters = () => { // Consecutive URL-state setters clobber each other (each reads the not-yet- // updated params), so clear with exactly one call per case. - if (hasSearchQuery && hasTagFilter) { + const active = [hasSearchQuery, hasTagFilter, consentFilter].filter(Boolean).length; + if (active > 1) { resetFilters(); + } else if (consentFilter) { + setFilter("consent", null); } else if (hasTagFilter) { setFilter("tag", null); } else { @@ -155,6 +176,12 @@ export function CustomersTable({ : "N/A", orders: c._count.orders, createdAt: format(new Date(c.createdAt), "yyyy-MM-dd"), + // BS-305: consent travels with every export; the rows are the + // on-screen (already filtered) page, so "Consented only" is honoured. + marketingConsent: c.marketingConsentAt ? "yes" : "no", + marketingConsentAt: c.marketingConsentAt + ? format(new Date(c.marketingConsentAt), "yyyy-MM-dd HH:mm") + : "", })); const csvHeaders = [ @@ -164,6 +191,8 @@ export function CustomersTable({ { key: "status" as const, label: "Status" }, { key: "orders" as const, label: "Orders" }, { key: "createdAt" as const, label: "Joined" }, + { key: "marketingConsent" as const, label: "Marketing consent" }, + { key: "marketingConsentAt" as const, label: "Consent given" }, ]; await exportToCSV( @@ -227,6 +256,13 @@ export function CustomersTable({ /> )} + setFilter("consent", value || null)} + options={CONSENT_FILTER_OPTIONS} + aria-label="Filter by marketing consent" + /> + {statusCounts.NOT_SUBMITTED} not submitted + {typeof consentedCount === "number" && ( + {consentedCount} consented to marketing + )}
{canRefresh && (
@@ -282,7 +321,7 @@ export function CustomersTable({ variant="muted" size="default" action={{ - label: hasTagFilter ? "Clear filters" : "Clear search", + label: hasTagFilter || consentFilter ? "Clear filters" : "Clear search", onClick: handleClearFilters, variant: "outline", }} @@ -324,6 +363,7 @@ export function CustomersTable({ className="hidden md:table-cell" /> Status + Marketing
+ {/* BS-303: optional salutation — the customer's own choice, never + inferred from an identity document. */} +
+ + +
+
@@ -346,7 +379,7 @@ export function ContactDetailsStep({ onChange={(e) => onUpdate({ marketingConsent: e.target.checked })} className="h-4 w-4 mt-0.5 text-emerald-600 focus:ring-emerald-500" /> - Email me offers and updates + {marketingConsentCopy(storeName)}
diff --git a/nextjs_space/components/consultation/steps/id-upload-step.tsx b/nextjs_space/components/consultation/steps/id-upload-step.tsx index 67a85ddb..bd1a4ee2 100644 --- a/nextjs_space/components/consultation/steps/id-upload-step.tsx +++ b/nextjs_space/components/consultation/steps/id-upload-step.tsx @@ -12,6 +12,7 @@ import { SelectValue, } from "@/components/ui/select"; import { UploadCloud, FileCheck2 } from "lucide-react"; +import { saIdFieldError } from "@/lib/verification/sa-id"; export type IdDocumentType = "ID" | "PASSPORT" | "DRIVING_LICENCE"; @@ -30,6 +31,17 @@ interface IdUploadStepProps { onSubmit: () => void; onBack: () => void; isSubmitting: boolean; + /** + * BS-203: an SA_ID_INVALID answer from the submit route (or from Dr Green + * through it) — shown on the number field, never as a generic banner. + */ + documentNumberError?: string | null; + /** + * Apply the South African ID rules to the ID option. This step only renders + * on ID-upload tenants, which are South African by construction + * (lib/verification-mode.ts), so the rules are on unless a caller says not. + */ + validateSaId?: boolean; } export function IdUploadStep({ @@ -41,10 +53,16 @@ export function IdUploadStep({ onSubmit, onBack, isSubmitting, + documentNumberError = null, + validateSaId = true, }: IdUploadStepProps) { const [error, setError] = useState(null); + const [numberError, setNumberError] = useState(null); const inputRef = useRef(null); + const idOptionLabel = validateSaId ? "South African ID" : "National ID"; + const fieldError = numberError ?? documentNumberError; + const pick = (e: React.ChangeEvent) => { const f = e.target.files?.[0] ?? null; setError(null); @@ -55,10 +73,22 @@ export function IdUploadStep({ onFileChange(f); }; + // BS-203: the shared rules, on blur and on submit, only for the ID option. + const checkNumber = (): boolean => { + const message = saIdFieldError({ + documentType, + documentNumber, + enforce: validateSaId, + }); + setNumberError(message); + return message === null; + }; + const submit = () => { if (!file) return setError("Please upload a photo of your ID."); if (!documentNumber.trim()) return setError("Please enter your document number."); + if (!checkNumber()) return; setError(null); onSubmit(); }; @@ -70,9 +100,9 @@ export function IdUploadStep({ Verify your identity

- Upload a clear photo of a valid government ID (National - ID, passport or driving licence). It must be your actual ID - document — selfies or other photos will be rejected. + Upload a clear photo of a valid government ID ( + {idOptionLabel}, passport or driving licence). It must be your actual + ID document — selfies or other photos will be rejected. An admin reviews it to verify your account — no medical consultation needed.

@@ -82,13 +112,16 @@ export function IdUploadStep({ onUpdate({ documentNumber: e.target.value })} + onChange={(e) => { + setNumberError(null); + onUpdate({ documentNumber: e.target.value }); + }} + onBlur={checkNumber} + inputMode={documentType === "ID" && validateSaId ? "numeric" : "text"} + aria-invalid={fieldError ? true : undefined} + aria-describedby={fieldError ? "documentNumber-error" : undefined} + className={fieldError ? "border-red-500" : ""} placeholder="As shown on your document" /> + {fieldError && ( +

+ {fieldError} +

+ )}
diff --git a/nextjs_space/components/shop/ClientOnboarding.tsx b/nextjs_space/components/shop/ClientOnboarding.tsx index f14e4540..78fb9152 100644 --- a/nextjs_space/components/shop/ClientOnboarding.tsx +++ b/nextjs_space/components/shop/ClientOnboarding.tsx @@ -42,6 +42,7 @@ export function ClientOnboarding() { const personalForm = useForm({ resolver: zodResolver(personalDetailsSchema), defaultValues: formData.personal || { + title: "", firstName: "", lastName: "", email: user?.primaryEmailAddress?.emailAddress || "", @@ -69,6 +70,7 @@ export function ClientOnboarding() { previousCannabisUse: false, doctorApproval: false, consent: false, + marketingConsent: false, }, }); @@ -104,6 +106,10 @@ export function ClientOnboarding() { personal: formData.personal, address: formData.address, medicalRecord: data, + // Phase 3 (BS-301..303): top-level so the route never reads + // consent out of the medical record. + title: formData.personal?.title || undefined, + marketingConsent: data.marketingConsent === true, }), }); diff --git a/nextjs_space/components/shop/IdDocumentUpload.tsx b/nextjs_space/components/shop/IdDocumentUpload.tsx index ebb54be9..065368d1 100644 --- a/nextjs_space/components/shop/IdDocumentUpload.tsx +++ b/nextjs_space/components/shop/IdDocumentUpload.tsx @@ -2,26 +2,53 @@ import { useState } from "react"; import { Loader2, Upload, CheckCircle2, AlertCircle } from "lucide-react"; +import { SA_ID_INVALID_CODE, saIdFieldError } from "@/lib/verification/sa-id"; // Mirror the server limits (drgreen-identity.ts / Dr Green identity.service.ts) // for fast client-side feedback; the server remains the source of truth. const ALLOWED_MIME = ["image/jpeg", "image/png", "application/pdf"]; const MAX_BYTES = 10 * 1024 * 1024; -const DOC_TYPES = [ - { value: "ID", label: "National ID" }, - { value: "PASSPORT", label: "Passport" }, - { value: "DRIVING_LICENCE", label: "Driving licence" }, -] as const; - type UploadState = "idle" | "submitting" | "pending" | "error"; -export function IdDocumentUpload({ slug }: { slug: string }) { +/** + * Stand-alone ID upload card (posts to the same pass-through route as the + * dashboard re-upload). BS-203: South African ID rules inline for the ID + * option, and an SA_ID_INVALID answer from the route lands on the number + * field rather than the failed-upload banner. Only ever mounted on ID-upload + * tenants, which are South African by construction, so `validateSaId` + * defaults on. + */ +export function IdDocumentUpload({ + slug, + validateSaId = true, +}: { + slug: string; + validateSaId?: boolean; +}) { const [file, setFile] = useState(null); const [documentType, setDocumentType] = useState("ID"); const [documentNumber, setDocumentNumber] = useState(""); const [state, setState] = useState("idle"); const [error, setError] = useState(null); + const [numberError, setNumberError] = useState(null); + + const idOptionLabel = validateSaId ? "South African ID" : "National ID"; + const docTypes = [ + { value: "ID", label: idOptionLabel }, + { value: "PASSPORT", label: "Passport" }, + { value: "DRIVING_LICENCE", label: "Driving licence" }, + ]; + + const checkNumber = (): boolean => { + const message = saIdFieldError({ + documentType, + documentNumber, + enforce: validateSaId, + }); + setNumberError(message); + return message === null; + }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -34,6 +61,7 @@ export function IdDocumentUpload({ slug }: { slug: string }) { return setError("File must be 10MB or smaller."); if (!documentNumber.trim()) return setError("Please enter the document number."); + if (!checkNumber()) return; setState("submitting"); try { @@ -47,7 +75,14 @@ export function IdDocumentUpload({ slug }: { slug: string }) { body: form, }); const data = await res.json().catch(() => ({})); - if (!res.ok) throw new Error(data?.error || "Upload failed. Please try again."); + if (!res.ok) { + if (data?.code === SA_ID_INVALID_CODE) { + setNumberError(data.error); + setState("idle"); + return; + } + throw new Error(data?.error || "Upload failed. Please try again."); + } setState("pending"); } catch (err: any) { @@ -96,11 +131,14 @@ export function IdDocumentUpload({ slug }: { slug: string }) { setDocumentNumber(e.target.value)} + onChange={(e) => { + setNumberError(null); + setDocumentNumber(e.target.value); + }} + onBlur={checkNumber} + inputMode={documentType === "ID" && validateSaId ? "numeric" : "text"} + aria-invalid={numberError ? true : undefined} + aria-describedby={numberError ? "id-upload-doc-number-error" : undefined} maxLength={100} disabled={submitting} placeholder="As printed on the document" - className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm" + className={`w-full rounded-lg border px-3 py-2 text-sm ${ + numberError ? "border-red-500" : "border-slate-300" + }`} /> + {numberError && ( +

+ {numberError} +

+ )}
diff --git a/nextjs_space/components/shop/ReUploadIdDocument.tsx b/nextjs_space/components/shop/ReUploadIdDocument.tsx index 4c48df3a..ccd13459 100644 --- a/nextjs_space/components/shop/ReUploadIdDocument.tsx +++ b/nextjs_space/components/shop/ReUploadIdDocument.tsx @@ -13,6 +13,7 @@ import { } from "@/components/ui/select"; import { toast } from "@/components/ui/sonner"; import { UploadCloud, FileCheck2 } from "lucide-react"; +import { SA_ID_INVALID_CODE, saIdFieldError } from "@/lib/verification/sa-id"; type IdDocumentType = "ID" | "PASSPORT" | "DRIVING_LICENCE"; @@ -26,13 +27,20 @@ const MAX_BYTES = 10 * 1024 * 1024; // 10 MB * PRD-220 Part B — dashboard re-upload for a failed inline ID upload. * Posts multipart to /api/store/[slug]/verify/id-document (the existing * pass-through endpoint; nothing about the document is stored on our side). + * + * BS-203: the South African ID rules run inline (blur + submit) for the ID + * option, and an SA_ID_INVALID answer from the route lands on the number + * field. This card only renders on ID-upload tenants, which are South African + * by construction, so `validateSaId` defaults on. */ export function ReUploadIdDocument({ slug, onUploaded, + validateSaId = true, }: { slug: string; onUploaded?: () => void; + validateSaId?: boolean; }) { const inputRef = useRef(null); const [file, setFile] = useState(null); @@ -40,6 +48,9 @@ export function ReUploadIdDocument({ const [documentNumber, setDocumentNumber] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + const [numberError, setNumberError] = useState(null); + + const idOptionLabel = validateSaId ? "South African ID" : "National ID"; const pick = (e: React.ChangeEvent) => { const f = e.target.files?.[0] ?? null; @@ -52,9 +63,20 @@ export function ReUploadIdDocument({ setFile(f); }; + const checkNumber = (): boolean => { + const message = saIdFieldError({ + documentType, + documentNumber, + enforce: validateSaId, + }); + setNumberError(message); + return message === null; + }; + const submit = async () => { if (!file) return setError("Please choose your ID document."); if (!documentNumber.trim()) return setError("Please enter your document number."); + if (!checkNumber()) return; setError(null); setSubmitting(true); try { @@ -69,6 +91,10 @@ export function ReUploadIdDocument({ }); if (!res.ok) { const body = await res.json().catch(() => null); + if (body?.code === SA_ID_INVALID_CODE) { + setNumberError(body.error); + return; + } throw new Error(body?.error || "Upload failed. Please try again."); } @@ -88,13 +114,16 @@ export function ReUploadIdDocument({ setDocumentNumber(e.target.value)} + onChange={(e) => { + setNumberError(null); + setDocumentNumber(e.target.value); + }} + onBlur={checkNumber} + inputMode={documentType === "ID" && validateSaId ? "numeric" : "text"} + aria-invalid={numberError ? true : undefined} + aria-describedby={numberError ? "reupload-doc-number-error" : undefined} + className={numberError ? "border-red-500" : ""} placeholder="As shown on your document" /> + {numberError && ( +

+ {numberError} +

+ )}
diff --git a/nextjs_space/components/shop/onboarding/MedicalStep.tsx b/nextjs_space/components/shop/onboarding/MedicalStep.tsx index 45d86e73..c84011d7 100644 --- a/nextjs_space/components/shop/onboarding/MedicalStep.tsx +++ b/nextjs_space/components/shop/onboarding/MedicalStep.tsx @@ -16,12 +16,15 @@ import { } from "@/components/ui/form"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import type { Medical } from "./onboarding-schema"; +import { marketingConsentCopy } from "@/lib/customers/marketing-consent"; interface MedicalStepProps { form: UseFormReturn; onSubmit: (data: Medical) => Promise | void; onBack: () => void; isSubmitting: boolean; + /** Read into the marketing-consent copy (BS-302). */ + storeName?: string; } export function MedicalStep({ @@ -29,6 +32,7 @@ export function MedicalStep({ onSubmit, onBack, isSubmitting, + storeName, }: MedicalStepProps) { return ( )} /> + {/* BS-302 (POPIA): marketing consent — optional, UNTICKED by + default, separate from the required terms consent above. */} + ( + + + field.onChange(checked === true)} + /> + +
+ + {marketingConsentCopy(storeName)} + +

+ Optional. You can change this any time in your account + settings. +

+
+
+ )} + />