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
+ )}
diff --git a/nextjs_space/app/tenant-admin/customers/page.tsx b/nextjs_space/app/tenant-admin/customers/page.tsx
index 96d07ade..08382ce6 100644
--- a/nextjs_space/app/tenant-admin/customers/page.tsx
+++ b/nextjs_space/app/tenant-admin/customers/page.tsx
@@ -85,6 +85,10 @@ export default async function CustomersListPage({
// US-024: tag filter param, matched in the tag's canonical form. A malformed
// value (blank after trim, over-long) is treated as no filter — a shared URL
// should degrade to the full list, not an error page.
+ // BS-305: "Consented only" — customers who opted in to marketing
+ // (users.marketingConsentAt set). Same test the campaign audience uses.
+ const consentFilter = params.consent === "yes";
+
const rawTag = typeof params.tag === "string" ? params.tag : "";
const parsedTag = rawTag ? tagSchema.safeParse(rawTag) : null;
const tagFilter = parsedTag?.success ? parsedTag.data : "";
@@ -117,6 +121,7 @@ export default async function CustomersListPage({
role: "PATIENT",
...(tenantId && { tenantId }),
...notErased,
+ ...(consentFilter && { marketingConsentAt: { not: null } }),
};
// Apply search filter (case-insensitive across multiple fields)
@@ -149,7 +154,7 @@ export default async function CustomersListPage({
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
- const [filteredCount, rawCustomers, totalCustomersCount, recentSignupsCount, tenantQuestionnaires, availableTags, allCustomerEmails, lastStatusRefresh] =
+ const [filteredCount, rawCustomers, totalCustomersCount, recentSignupsCount, tenantQuestionnaires, availableTags, allCustomerEmails, lastStatusRefresh, consentedCount] =
await Promise.all([
prisma.users.count({ where: whereClause }),
prisma.users.findMany({
@@ -160,6 +165,7 @@ export default async function CustomersListPage({
name: true,
phone: true,
createdAt: true,
+ marketingConsentAt: true,
_count: {
select: {
orders: true,
@@ -230,6 +236,16 @@ export default async function CustomersListPage({
select: { createdAt: true },
})
: Promise.resolve(null),
+ // BS-305: tenant-wide consented count for the summary pill (unfiltered,
+ // like the other counts).
+ prisma.users.count({
+ where: {
+ role: "PATIENT",
+ ...(tenantId && { tenantId }),
+ ...notErased,
+ marketingConsentAt: { not: null },
+ },
+ }),
]);
// Backfill name/phone for customers whose intake saved the name only to
@@ -263,7 +279,12 @@ export default async function CustomersListPage({
};
const customers = rawCustomers.map(
- (customer: { email: string; name: string | null; phone: string | null }) => {
+ (customer: {
+ email: string;
+ name: string | null;
+ phone: string | null;
+ marketingConsentAt: Date | null;
+ }) => {
const q = questionnaireByEmail.get(customer.email.toLowerCase());
return {
...customer,
@@ -328,6 +349,7 @@ export default async function CustomersListPage({
diff --git a/nextjs_space/components/consultation/id-upload-form.tsx b/nextjs_space/components/consultation/id-upload-form.tsx
index b4361fac..9c3a9868 100644
--- a/nextjs_space/components/consultation/id-upload-form.tsx
+++ b/nextjs_space/components/consultation/id-upload-form.tsx
@@ -7,6 +7,7 @@ import { ContactDetailsStep } from "./steps/contact-details-step";
import { AddressStep } from "./steps/address-step";
import { IdUploadStep, type IdDocumentType } from "./steps/id-upload-step";
import { toast } from "@/components/ui/sonner";
+import { SA_ID_INVALID_CODE, SA_ID_INVALID_MESSAGE } from "@/lib/verification/sa-id";
import { useRouter } from "next/navigation";
import type { ConsultationFormData } from "./consultation-form-types";
@@ -22,6 +23,8 @@ const STEP_NAMES = ["Contact Details", "Address Information", "Verify Identity"]
interface IdUploadFormProps {
tenantSlug: string;
+ /** The store's business name — read into the marketing-consent copy (BS-302). */
+ storeName?: string;
}
const fileToBase64 = (file: File): Promise =>
@@ -36,7 +39,7 @@ const fileToBase64 = (file: File): Promise =>
reader.readAsDataURL(file);
});
-export function IdUploadForm({ tenantSlug }: IdUploadFormProps) {
+export function IdUploadForm({ tenantSlug, storeName }: IdUploadFormProps) {
const router = useRouter();
const [currentStep, setCurrentStep] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -44,6 +47,8 @@ export function IdUploadForm({ tenantSlug }: IdUploadFormProps) {
const [idFile, setIdFile] = useState(null);
const [documentType, setDocumentType] = useState("ID");
const [documentNumber, setDocumentNumber] = useState("");
+ // BS-203: an SA_ID_INVALID answer from the server lands on the number field.
+ const [documentNumberError, setDocumentNumberError] = useState(null);
const [formData, setFormData] = useState({
firstName: "",
@@ -56,6 +61,7 @@ export function IdUploadForm({ tenantSlug }: IdUploadFormProps) {
password: "",
confirmPassword: "",
marketingConsent: false,
+ title: "",
addressLine1: "",
addressLine2: "",
@@ -145,6 +151,12 @@ export function IdUploadForm({ tenantSlug }: IdUploadFormProps) {
const result = await response.json();
if (!response.ok) {
+ if (result?.code === SA_ID_INVALID_CODE) {
+ // The number, not the upload, is the problem: show it on the field
+ // and keep the customer on this step — no generic failure toast.
+ setDocumentNumberError(result.error || SA_ID_INVALID_MESSAGE);
+ return;
+ }
throw new Error(result.error || "Registration failed");
}
@@ -169,6 +181,7 @@ export function IdUploadForm({ tenantSlug }: IdUploadFormProps) {
return (
@@ -189,7 +202,9 @@ export function IdUploadForm({ tenantSlug }: IdUploadFormProps) {
documentType={documentType}
documentNumber={documentNumber}
onFileChange={setIdFile}
+ documentNumberError={documentNumberError}
onUpdate={(d) => {
+ setDocumentNumberError(null);
if (d.documentType !== undefined) setDocumentType(d.documentType);
if (d.documentNumber !== undefined)
setDocumentNumber(d.documentNumber);
diff --git a/nextjs_space/components/consultation/steps/contact-details-step.tsx b/nextjs_space/components/consultation/steps/contact-details-step.tsx
index eb849d5d..49ea43a4 100644
--- a/nextjs_space/components/consultation/steps/contact-details-step.tsx
+++ b/nextjs_space/components/consultation/steps/contact-details-step.tsx
@@ -21,18 +21,27 @@ import { CalendarIcon, Eye, EyeOff } from "lucide-react";
import { format } from "date-fns";
import type { ConsultationFormData } from "../consultation-form-types";
import { COUNTRY_CODES } from "@/lib/consultation-constants";
+import { CUSTOMER_TITLES } from "@/lib/customers/titles";
+import { marketingConsentCopy } from "@/lib/customers/marketing-consent";
import { cn } from "@/lib/utils";
interface ContactDetailsStepProps {
data: ConsultationFormData;
onUpdate: (data: Partial) => void;
onNext: () => void;
+ /** Read into the marketing-consent copy (BS-302). */
+ storeName?: string;
}
+// Radix Select rejects an empty-string item value, so "not chosen" is a
+// sentinel that maps back to "" in the form data.
+const NO_TITLE = "none";
+
export function ContactDetailsStep({
data,
onUpdate,
onNext,
+ storeName,
}: ContactDetailsStepProps) {
const [errors, setErrors] = useState>({});
const [showPassword, setShowPassword] = useState(false);
@@ -80,6 +89,30 @@ export function ContactDetailsStep({
+ {/* 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({
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 }) {
-
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({
Document type
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.
+