From 997db2316f43406d38a2aaec1b3cb9e2bc8532d5 Mon Sep 17 00:00:00 2001 From: Elias Date: Mon, 29 Jun 2026 16:36:21 +0300 Subject: [PATCH 01/12] feat(checkout): add M-Pesa (STK push) payment to headless checkout The v3 Store API direct-payment endpoint cannot carry a payment source, so M-Pesa was unsupported. Add a dedicated M-Pesa phone-number field for the M-Pesa method and send the number in the payment metadata bag, which the backend reads to build the source and trigger the STK push. M-Pesa confirmation is asynchronous, so route the M-Pesa checkout to a new awaiting-payment screen that polls the order's payment state and only advances to the thank-you page once payment is confirmed, instead of the moment the STK is sent. Adds getOrderPaymentStatus and createDirectPayment metadata test coverage. --- messages/de.json | 10 ++ messages/en.json | 10 ++ messages/es.json | 10 ++ messages/fr.json | 10 ++ messages/pl.json | 10 ++ .../(checkout)/awaiting-payment/[id]/page.tsx | 101 +++++++++++++++++ .../checkout/[id]/CheckoutPageContent.tsx | 7 ++ src/components/checkout/PaymentSection.tsx | 72 +++++++++++- src/lib/data/__tests__/payment.test.ts | 104 ++++++++++++++++++ src/lib/data/payment.ts | 32 +++++- src/lib/utils/mpesa.ts | 24 ++++ 11 files changed, 385 insertions(+), 5 deletions(-) create mode 100644 src/app/[country]/[locale]/(checkout)/awaiting-payment/[id]/page.tsx create mode 100644 src/lib/utils/mpesa.ts diff --git a/messages/de.json b/messages/de.json index 42646baf..00346e9c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -232,6 +232,16 @@ "confirmingPayment": "Ihre Zahlung wird bestätigt...", "selectPaymentMethod": "Bitte wählen Sie eine Zahlungsmethode.", "manualPaymentInfo": "Sie erhalten Zahlungsanweisungen nach der Bestellung.", + "mpesaPhoneLabel": "M-Pesa-Telefonnummer", + "mpesaPhonePlaceholder": "07XX XXX XXX", + "mpesaPhoneHint": "Geben Sie die Safaricom-Nummer ein, um die M-Pesa-Zahlungsaufforderung zu erhalten.", + "mpesaPhoneInvalid": "Geben Sie eine gültige Safaricom-Nummer ein, z. B. 0712 345 678.", + "mpesaAwaitTitle": "Prüfen Sie Ihr Telefon", + "mpesaAwaitBody": "Wir haben eine M-Pesa-Zahlungsaufforderung an Ihr Telefon gesendet. Geben Sie Ihre M-Pesa-PIN ein, um die Zahlung abzuschließen.", + "mpesaTimeoutTitle": "Warten weiterhin auf Bestätigung", + "mpesaTimeoutBody": "Wir haben Ihre M-Pesa-Bestätigung noch nicht erhalten. Wenn Sie Ihre PIN bereits eingegeben haben, kann die Verarbeitung einen Moment dauern. Sie können den Bestellstatus unten prüfen.", + "mpesaCheckOrderStatus": "Bestellstatus prüfen", + "mpesaPaymentFailed": "Die M-Pesa-Zahlung wurde nicht abgeschlossen. Bitte versuchen Sie es erneut.", "failedToCreatePayment": "Zahlung konnte nicht erstellt werden. Bitte versuchen Sie es erneut.", "placeOrder": "Bestellung aufgeben", "noPaymentRequired": "Keine Zahlung erforderlich — Ihre Bestellung ist vollständig abgedeckt.", diff --git a/messages/en.json b/messages/en.json index 01103cc4..e858efd5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -232,6 +232,16 @@ "confirmingPayment": "Confirming your payment...", "selectPaymentMethod": "Please select a payment method.", "manualPaymentInfo": "You will receive payment instructions after placing your order.", + "mpesaPhoneLabel": "M-Pesa phone number", + "mpesaPhonePlaceholder": "07XX XXX XXX", + "mpesaPhoneHint": "Enter the Safaricom number to receive the M-Pesa payment prompt.", + "mpesaPhoneInvalid": "Enter a valid Safaricom number, e.g. 0712 345 678.", + "mpesaAwaitTitle": "Check your phone", + "mpesaAwaitBody": "We've sent an M-Pesa payment request to your phone. Enter your M-Pesa PIN to complete the payment.", + "mpesaTimeoutTitle": "Still waiting for confirmation", + "mpesaTimeoutBody": "We haven't received your M-Pesa confirmation yet. If you already entered your PIN, it may take a moment to settle. You can check your order status below.", + "mpesaCheckOrderStatus": "Check order status", + "mpesaPaymentFailed": "The M-Pesa payment was not completed. Please try again.", "failedToCreatePayment": "Failed to create payment. Please try again.", "placeOrder": "Place Order", "noPaymentRequired": "No payment required — your order is fully covered.", diff --git a/messages/es.json b/messages/es.json index 2ab5f75f..c269b5e5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -232,6 +232,16 @@ "confirmingPayment": "Confirmando tu pago...", "selectPaymentMethod": "Por favor selecciona un método de pago.", "manualPaymentInfo": "Recibirás instrucciones de pago después de realizar tu pedido.", + "mpesaPhoneLabel": "Número de teléfono M-Pesa", + "mpesaPhonePlaceholder": "07XX XXX XXX", + "mpesaPhoneHint": "Ingresa el número de Safaricom para recibir la solicitud de pago de M-Pesa.", + "mpesaPhoneInvalid": "Ingresa un número de Safaricom válido, p. ej. 0712 345 678.", + "mpesaAwaitTitle": "Revisa tu teléfono", + "mpesaAwaitBody": "Hemos enviado una solicitud de pago M-Pesa a tu teléfono. Ingresa tu PIN de M-Pesa para completar el pago.", + "mpesaTimeoutTitle": "Esperando confirmación", + "mpesaTimeoutBody": "Aún no hemos recibido tu confirmación de M-Pesa. Si ya ingresaste tu PIN, puede tardar un momento en procesarse. Puedes revisar el estado de tu pedido abajo.", + "mpesaCheckOrderStatus": "Ver estado del pedido", + "mpesaPaymentFailed": "El pago de M-Pesa no se completó. Inténtalo de nuevo.", "failedToCreatePayment": "No se pudo crear el pago. Inténtalo de nuevo.", "placeOrder": "Realizar pedido", "noPaymentRequired": "No se requiere pago — tu pedido está completamente cubierto.", diff --git a/messages/fr.json b/messages/fr.json index a07f1713..c704d786 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -232,6 +232,16 @@ "confirmingPayment": "Confirmation de votre paiement...", "selectPaymentMethod": "Veuillez sélectionner un mode de paiement.", "manualPaymentInfo": "Vous recevrez les instructions de paiement après avoir passé votre commande.", + "mpesaPhoneLabel": "Numéro de téléphone M-Pesa", + "mpesaPhonePlaceholder": "07XX XXX XXX", + "mpesaPhoneHint": "Saisissez le numéro Safaricom pour recevoir la demande de paiement M-Pesa.", + "mpesaPhoneInvalid": "Saisissez un numéro Safaricom valide, par ex. 0712 345 678.", + "mpesaAwaitTitle": "Vérifiez votre téléphone", + "mpesaAwaitBody": "Nous avons envoyé une demande de paiement M-Pesa sur votre téléphone. Saisissez votre code PIN M-Pesa pour finaliser le paiement.", + "mpesaTimeoutTitle": "En attente de confirmation", + "mpesaTimeoutBody": "Nous n'avons pas encore reçu votre confirmation M-Pesa. Si vous avez déjà saisi votre code PIN, le traitement peut prendre un moment. Vous pouvez vérifier l'état de votre commande ci-dessous.", + "mpesaCheckOrderStatus": "Vérifier l'état de la commande", + "mpesaPaymentFailed": "Le paiement M-Pesa n'a pas abouti. Veuillez réessayer.", "failedToCreatePayment": "Échec de la création du paiement. Veuillez réessayer.", "placeOrder": "Passer la commande", "noPaymentRequired": "Aucun paiement requis — votre commande est entièrement couverte.", diff --git a/messages/pl.json b/messages/pl.json index 388315c8..ce779c26 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -232,6 +232,16 @@ "confirmingPayment": "Potwierdzanie płatności...", "selectPaymentMethod": "Proszę wybrać metodę płatności.", "manualPaymentInfo": "Otrzymasz instrukcje płatności po złożeniu zamówienia.", + "mpesaPhoneLabel": "Numer telefonu M-Pesa", + "mpesaPhonePlaceholder": "07XX XXX XXX", + "mpesaPhoneHint": "Wprowadź numer Safaricom, aby otrzymać monit o płatność M-Pesa.", + "mpesaPhoneInvalid": "Wprowadź prawidłowy numer Safaricom, np. 0712 345 678.", + "mpesaAwaitTitle": "Sprawdź telefon", + "mpesaAwaitBody": "Wysłaliśmy prośbę o płatność M-Pesa na Twój telefon. Wprowadź swój PIN M-Pesa, aby dokończyć płatność.", + "mpesaTimeoutTitle": "Wciąż czekamy na potwierdzenie", + "mpesaTimeoutBody": "Nie otrzymaliśmy jeszcze potwierdzenia M-Pesa. Jeśli już wprowadziłeś PIN, przetworzenie może chwilę potrwać. Status zamówienia możesz sprawdzić poniżej.", + "mpesaCheckOrderStatus": "Sprawdź status zamówienia", + "mpesaPaymentFailed": "Płatność M-Pesa nie została zrealizowana. Spróbuj ponownie.", "failedToCreatePayment": "Nie udało się utworzyć płatności. Spróbuj ponownie.", "placeOrder": "Złóż zamówienie", "noPaymentRequired": "Płatność nie jest wymagana — zamówienie jest w pełni pokryte.", diff --git a/src/app/[country]/[locale]/(checkout)/awaiting-payment/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/awaiting-payment/[id]/page.tsx new file mode 100644 index 00000000..9ffc6034 --- /dev/null +++ b/src/app/[country]/[locale]/(checkout)/awaiting-payment/[id]/page.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { CircleAlert, Loader2, Smartphone } from "lucide-react"; +import { usePathname, useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { use, useEffect, useRef, useState } from "react"; +import { getOrderPaymentStatus } from "@/lib/data/payment"; +import { extractBasePath } from "@/lib/utils/path"; + +const POLL_INTERVAL_MS = 3000; +const POLL_TIMEOUT_MS = 120000; + +interface AwaitingPaymentPageProps { + params: Promise<{ + id: string; + country: string; + locale: string; + }>; +} + +export default function AwaitingPaymentPage({ + params, +}: AwaitingPaymentPageProps) { + const { id: cartId } = use(params); + const t = useTranslations("checkout"); + const router = useRouter(); + const pathname = usePathname(); + const basePath = extractBasePath(pathname); + const [timedOut, setTimedOut] = useState(false); + const startedRef = useRef(false); + + useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + + let active = true; + const deadline = Date.now() + POLL_TIMEOUT_MS; + + async function poll() { + while (active) { + const { state } = await getOrderPaymentStatus(cartId); + if (!active) return; + + if (state === "completed") { + router.replace(`${basePath}/order-placed/${cartId}`); + return; + } + if (state === "failed") { + const message = encodeURIComponent(t("mpesaPaymentFailed")); + router.replace( + `${basePath}/checkout/${cartId}?payment_error=${message}`, + ); + return; + } + if (Date.now() >= deadline) { + setTimedOut(true); + return; + } + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + } + + poll(); + + return () => { + active = false; + }; + }, [cartId, basePath, router, t]); + + if (timedOut) { + return ( +
+ +

+ {t("mpesaTimeoutTitle")} +

+

+ {t("mpesaTimeoutBody")} +

+ +
+ ); + } + + return ( +
+ + +

+ {t("mpesaAwaitTitle")} +

+

{t("mpesaAwaitBody")}

+
+ ); +} diff --git a/src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx b/src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx index 1fc9e4f7..990546ec 100644 --- a/src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx +++ b/src/app/[country]/[locale]/(checkout)/checkout/[id]/CheckoutPageContent.tsx @@ -452,6 +452,13 @@ function CheckoutPageContentInner({ cacheCompletedOrder(currentOrder.id, completeResult.order); } + if (result.type === "direct" && result.awaitConfirmation) { + routerRef.current.push( + `${basePath}/awaiting-payment/${currentOrder.id}`, + ); + return; + } + routerRef.current.push(`${basePath}/order-placed/${currentOrder.id}`); } catch { setError(tRef.current("generalError")); diff --git a/src/components/checkout/PaymentSection.tsx b/src/components/checkout/PaymentSection.tsx index 19537e5f..7bbd8b85 100644 --- a/src/components/checkout/PaymentSection.tsx +++ b/src/components/checkout/PaymentSection.tsx @@ -49,12 +49,17 @@ import { updateAddressField, } from "@/lib/utils/address"; import { getCardIconType, getCardLabel } from "@/lib/utils/credit-card"; +import { + isMpesaMethod, + isValidKenyanPhone, + normalizeKenyanPhone, +} from "@/lib/utils/mpesa"; import { extractBasePath } from "@/lib/utils/path"; import { resolveGatewayId } from "@/lib/utils/payment-gateway"; export type PaymentCompleteResult = | { type: "session"; sessionId: string; sessionResult?: string } - | { type: "direct" }; + | { type: "direct"; awaitConfirmation?: boolean }; export interface PaymentSectionHandle { submit: () => Promise<{ error?: string }>; @@ -142,6 +147,12 @@ export function PaymentSection({ const [useShippingForBilling, setUseShippingForBilling] = useState(initialUseShipping); + // Prefill from the shipping/billing phone so most customers just confirm. + const [mpesaPhone, setMpesaPhone] = useState( + () => cart.shipping_address?.phone ?? cart.billing_address?.phone ?? "", + ); + const [mpesaPhoneError, setMpesaPhoneError] = useState(null); + // ── Saved cards (session-based gateways only) ─────────────────────── const [savedCards, setSavedCards] = useState([]); // null = "add new payment method", string = gateway_payment_profile_id @@ -562,10 +573,21 @@ export function PaymentSection({ return {}; } - // Direct payment flow (Check, Cash on Delivery, etc.) + // Direct payment flow (Check, Cash on Delivery, M-Pesa, etc.) + let metadata: Record | undefined; + if (isMpesaMethod(selectedMethod.type)) { + if (!isValidKenyanPhone(mpesaPhone)) { + setMpesaPhoneError(t("mpesaPhoneInvalid")); + setProcessing(false); + return { error: t("mpesaPhoneInvalid") }; + } + metadata = { phone: normalizeKenyanPhone(mpesaPhone) }; + } + const paymentResult = await createDirectPayment( cart.id, selectedMethod.id, + metadata, ); if (!paymentResult.success) { const msg = paymentResult.error || t("failedToCreatePayment"); @@ -574,7 +596,10 @@ export function PaymentSection({ return { error: msg }; } - await onPaymentComplete({ type: "direct" }); + await onPaymentComplete({ + type: "direct", + awaitConfirmation: isMpesaMethod(selectedMethod.type), + }); return {}; } catch { const msg = t("paymentError"); @@ -595,6 +620,7 @@ export function PaymentSection({ selectedCardId, useShippingForBilling, billAddress, + mpesaPhone, onUpdateBillingAddress, onPaymentComplete, cart.id, @@ -910,6 +936,46 @@ export function PaymentSection({ } })()} + ) : isMpesaMethod(pm.type) ? ( + /* ── M-Pesa: explicit phone number ── */ +
+ {pm.description && ( +

+ {pm.description} +

+ )} + + { + setMpesaPhone(e.target.value); + if (mpesaPhoneError) setMpesaPhoneError(null); + }} + placeholder={t("mpesaPhonePlaceholder")} + className={`w-full rounded-sm border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 ${ + mpesaPhoneError ? "border-red-300" : "border-gray-300" + }`} + /> + {mpesaPhoneError ? ( +

+ + {mpesaPhoneError} +

+ ) : ( +

+ {t("mpesaPhoneHint")} +

+ )} +
) : ( /* ── Direct/manual payment ── */
diff --git a/src/lib/data/__tests__/payment.test.ts b/src/lib/data/__tests__/payment.test.ts index 5f179058..c9d7ffa3 100644 --- a/src/lib/data/__tests__/payment.test.ts +++ b/src/lib/data/__tests__/payment.test.ts @@ -9,6 +9,12 @@ const mockClient = { create: vi.fn(), complete: vi.fn(), }, + payments: { + create: vi.fn(), + }, + }, + orders: { + get: vi.fn(), }, }; @@ -35,6 +41,8 @@ import { completeCheckoutPaymentSession, confirmPaymentAndCompleteCart, createCheckoutPaymentSession, + createDirectPayment, + getOrderPaymentStatus, } from "@/lib/data/payment"; const mockSession = { @@ -99,6 +107,102 @@ describe("payment server actions", () => { }); }); + describe("createDirectPayment", () => { + const mockPayment = { id: "pay-1", state: "checkout" }; + + it("sends only the payment method id when no metadata is given", async () => { + mockClient.carts.payments.create.mockResolvedValue(mockPayment); + + const result = await createDirectPayment("cart-1", "pm-1"); + + expect(mockClient.carts.payments.create).toHaveBeenCalledWith( + "cart-1", + { payment_method_id: "pm-1" }, + { spreeToken: "order-token-123", token: undefined }, + ); + expect(result).toEqual({ success: true, payment: mockPayment }); + }); + + it("forwards the M-Pesa phone in the metadata bag", async () => { + mockClient.carts.payments.create.mockResolvedValue(mockPayment); + + await createDirectPayment("cart-1", "pm-1", { phone: "254712345678" }); + + expect(mockClient.carts.payments.create).toHaveBeenCalledWith( + "cart-1", + { + payment_method_id: "pm-1", + metadata: { phone: "254712345678" }, + }, + { spreeToken: "order-token-123", token: undefined }, + ); + }); + + it("returns error on failure", async () => { + mockClient.carts.payments.create.mockRejectedValue( + new Error("Payment rejected"), + ); + + const result = await createDirectPayment("cart-1", "pm-1"); + + expect(result).toEqual({ success: false, error: "Payment rejected" }); + }); + }); + + describe("getOrderPaymentStatus", () => { + it("returns completed when a payment has completed", async () => { + mockClient.orders.get.mockResolvedValue({ + payment_status: "balance_due", + payments: [{ status: "completed" }], + }); + + expect(await getOrderPaymentStatus("cart-1")).toEqual({ + state: "completed", + }); + }); + + it("returns completed when the order payment_status is paid", async () => { + mockClient.orders.get.mockResolvedValue({ + payment_status: "paid", + payments: [{ status: "pending" }], + }); + + expect(await getOrderPaymentStatus("cart-1")).toEqual({ + state: "completed", + }); + }); + + it("returns failed when every payment failed", async () => { + mockClient.orders.get.mockResolvedValue({ + payment_status: "balance_due", + payments: [{ status: "failed" }], + }); + + expect(await getOrderPaymentStatus("cart-1")).toEqual({ + state: "failed", + }); + }); + + it("returns pending while a payment is still pending", async () => { + mockClient.orders.get.mockResolvedValue({ + payment_status: "balance_due", + payments: [{ status: "pending" }], + }); + + expect(await getOrderPaymentStatus("cart-1")).toEqual({ + state: "pending", + }); + }); + + it("returns pending when the order cannot be fetched", async () => { + mockClient.orders.get.mockRejectedValue(new Error("not found")); + + expect(await getOrderPaymentStatus("cart-1")).toEqual({ + state: "pending", + }); + }); + }); + describe("completeCheckoutPaymentSession", () => { it("returns success with session", async () => { const completedSession = { ...mockSession, status: "completed" }; diff --git a/src/lib/data/payment.ts b/src/lib/data/payment.ts index 08182699..2e6859a8 100644 --- a/src/lib/data/payment.ts +++ b/src/lib/data/payment.ts @@ -30,18 +30,22 @@ export async function createCheckoutPaymentSession( /** * Creates a direct payment for non-session payment methods - * (e.g. Check, Cash on Delivery, Bank Transfer). + * (e.g. Check, Cash on Delivery, Bank Transfer, M-Pesa). */ export async function createDirectPayment( cartId: string, paymentMethodId: string, + metadata?: Record, ) { return actionResult(async () => { const options = await getCartOptions(); const id = await requireCartId(); const payment = await getClient().carts.payments.create( id, - { payment_method_id: paymentMethodId }, + { + payment_method_id: paymentMethodId, + ...(metadata && { metadata }), + }, options, ); updateTag("checkout"); @@ -49,6 +53,30 @@ export async function createDirectPayment( }, "Failed to create payment"); } +export async function getOrderPaymentStatus( + cartId: string, +): Promise<{ state: "pending" | "completed" | "failed" }> { + const order = await getOrder(cartId).catch(() => null); + if (!order) return { state: "pending" }; + + const payments = order.payments ?? []; + if ( + order.payment_status === "paid" || + payments.some((payment) => payment.status === "completed") + ) { + return { state: "completed" }; + } + if ( + payments.length > 0 && + payments.every( + (payment) => payment.status === "failed" || payment.status === "invalid", + ) + ) { + return { state: "failed" }; + } + return { state: "pending" }; +} + export async function completeCheckoutPaymentSession( cartId: string, sessionId: string, diff --git a/src/lib/utils/mpesa.ts b/src/lib/utils/mpesa.ts new file mode 100644 index 00000000..cf90f26e --- /dev/null +++ b/src/lib/utils/mpesa.ts @@ -0,0 +1,24 @@ +export const MPESA_PAYMENT_METHOD_TYPE = "Spree::PaymentMethod::Mpesa"; + +export function isMpesaMethod(paymentMethodType: string): boolean { + return paymentMethodType === MPESA_PAYMENT_METHOD_TYPE; +} + +export function normalizeKenyanPhone(raw: string): string { + const digits = raw.replace(/[^\d]/g, ""); + + if (/^0[17]\d{8}$/.test(digits)) { + return `254${digits.slice(1)}`; + } + if (/^[17]\d{8}$/.test(digits)) { + return `254${digits}`; + } + if (/^254[17]\d{8}$/.test(digits)) { + return digits; + } + return ""; +} + +export function isValidKenyanPhone(raw: string): boolean { + return normalizeKenyanPhone(raw).length > 0; +} From 94045c633e488a4fcb9329f113682c6fe41d7a22 Mon Sep 17 00:00:00 2001 From: Elias Date: Mon, 29 Jun 2026 16:37:30 +0300 Subject: [PATCH 02/12] fix(tenant): resolve tenant config inside the cache boundary In development the tenant lookup bypassed the \"use cache\" wrapper that production uses, so buildTenantConfigFromRecord ran new Date() in a bare server component. Under Next 16 Cache Components that throws during runtime prefetch, breaking every server-side fetch (markets, products), blank listings and 404 product pages. Always route resolveTenantConfigByHost through the cached resolver so the timestamp is read inside a cache boundary, matching production. --- src/lib/tenant/olitt.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/lib/tenant/olitt.ts b/src/lib/tenant/olitt.ts index f155b614..efab51ec 100644 --- a/src/lib/tenant/olitt.ts +++ b/src/lib/tenant/olitt.ts @@ -9,7 +9,6 @@ import { import type { TenantConfig } from "./types"; const DEFAULT_CACHE_TTL = "tenMinutes"; -const useTenantCache = process.env.NODE_ENV === "production"; type OlittMockEntry = | { @@ -231,10 +230,6 @@ export async function resolveTenantConfigByHost( normalizeHost(host, { preservePort: true }) ?? normalizeHost(host); if (!normalizedHost) return null; - if (!useTenantCache) { - return fetchTenantConfigFromOlitt(normalizedHost); - } - return cachedResolveTenantConfigByHost(normalizedHost); } From bfcff39a6047793c54c15506bc5ee7439f22679b Mon Sep 17 00:00:00 2001 From: Elias Date: Tue, 30 Jun 2026 07:00:29 +0300 Subject: [PATCH 03/12] feat(checkout): show Lipa na M-Pesa logo on the M-Pesa method Renders the Lipa na M-Pesa wordmark beside the M-Pesa payment option in both the multi-method and single-method header rows, gated by isMpesaMethod. Uses next/image with the unoptimized prop because Next rejects SVG optimization (HTTP 400) and a vector icon gains nothing from it; the asset is served from public/payment-icons/mpesa.svg with its viewBox tightened to the wordmark bounds so it renders cleanly at small icon sizes. --- public/payment-icons/mpesa.svg | 444 +++++++++++++++++++++ src/components/checkout/PaymentSection.tsx | 21 + 2 files changed, 465 insertions(+) create mode 100644 public/payment-icons/mpesa.svg diff --git a/public/payment-icons/mpesa.svg b/public/payment-icons/mpesa.svg new file mode 100644 index 00000000..84110897 --- /dev/null +++ b/public/payment-icons/mpesa.svg @@ -0,0 +1,444 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/checkout/PaymentSection.tsx b/src/components/checkout/PaymentSection.tsx index 7bbd8b85..e44b0fe9 100644 --- a/src/components/checkout/PaymentSection.tsx +++ b/src/components/checkout/PaymentSection.tsx @@ -9,6 +9,7 @@ import type { State, } from "@spree/sdk"; import { CircleAlert, CreditCard, Info, Loader2 } from "lucide-react"; +import Image from "next/image"; import { useTranslations } from "next-intl"; import { type Ref, @@ -732,6 +733,16 @@ export function PaymentSection({ } ${index > 0 ? "border-t" : ""}`} > + {isMpesaMethod(pm.type) && ( + M-Pesa + )} {pm.name} @@ -743,6 +754,16 @@ export function PaymentSection({
+ {isMpesaMethod(pm.type) && ( + M-Pesa + )} {pm.name} From b3e3c4e983c6377f25e03f3715d15043042f6153 Mon Sep 17 00:00:00 2001 From: Elias Date: Tue, 30 Jun 2026 10:20:21 +0300 Subject: [PATCH 04/12] fix(checkout): harden M-Pesa polling, phone normalization. The awaiting-payment poll awaited getOrderPaymentStatus with no error handling, so a single transient failure threw out of the loop and left the customer stuck on the spinner forever (the timeout check lives inside the dead loop). The fetch is now wrapped and a failure is treated as pending so polling continues until completion or timeout. normalizeKenyanPhone rejected numbers that carry both the country code and the trunk 0 (e.g. \"+254 0712 345 678\"), returning an empty string and blocking a valid Safaricom number. It now strips the redundant 0. The M-Pesa phone input set error styling but not aria-invalid, so assistive tech never announced the field as errored; it now reflects the error state. Adds unit coverage for the M-Pesa phone helpers, including the new country-code-plus-trunk-0 case. --- .../(checkout)/awaiting-payment/[id]/page.tsx | 7 ++- src/components/checkout/PaymentSection.tsx | 1 + src/lib/utils/__tests__/mpesa.test.ts | 53 +++++++++++++++++++ src/lib/utils/mpesa.ts | 3 ++ 4 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 src/lib/utils/__tests__/mpesa.test.ts diff --git a/src/app/[country]/[locale]/(checkout)/awaiting-payment/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/awaiting-payment/[id]/page.tsx index 9ffc6034..7cde2230 100644 --- a/src/app/[country]/[locale]/(checkout)/awaiting-payment/[id]/page.tsx +++ b/src/app/[country]/[locale]/(checkout)/awaiting-payment/[id]/page.tsx @@ -38,7 +38,12 @@ export default function AwaitingPaymentPage({ async function poll() { while (active) { - const { state } = await getOrderPaymentStatus(cartId); + let state: "pending" | "completed" | "failed" = "pending"; + try { + ({ state } = await getOrderPaymentStatus(cartId)); + } catch { + state = "pending"; + } if (!active) return; if (state === "completed") { diff --git a/src/components/checkout/PaymentSection.tsx b/src/components/checkout/PaymentSection.tsx index e44b0fe9..1a6ff81b 100644 --- a/src/components/checkout/PaymentSection.tsx +++ b/src/components/checkout/PaymentSection.tsx @@ -976,6 +976,7 @@ export function PaymentSection({ type="tel" inputMode="tel" autoComplete="tel" + aria-invalid={Boolean(mpesaPhoneError)} value={mpesaPhone} onChange={(e) => { setMpesaPhone(e.target.value); diff --git a/src/lib/utils/__tests__/mpesa.test.ts b/src/lib/utils/__tests__/mpesa.test.ts new file mode 100644 index 00000000..288de8f6 --- /dev/null +++ b/src/lib/utils/__tests__/mpesa.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { + isMpesaMethod, + isValidKenyanPhone, + MPESA_PAYMENT_METHOD_TYPE, + normalizeKenyanPhone, +} from "@/lib/utils/mpesa"; + +describe("isMpesaMethod", () => { + it("matches the Spree M-Pesa payment method type", () => { + expect(isMpesaMethod(MPESA_PAYMENT_METHOD_TYPE)).toBe(true); + }); + + it("rejects other payment method types", () => { + expect(isMpesaMethod("Spree::PaymentMethod::StripeGateway")).toBe(false); + }); +}); + +describe("normalizeKenyanPhone", () => { + it("converts a local 0-prefixed number to 254 format", () => { + expect(normalizeKenyanPhone("0712345678")).toBe("254712345678"); + }); + + it("prefixes a bare 9-digit number with 254", () => { + expect(normalizeKenyanPhone("712345678")).toBe("254712345678"); + }); + + it("accepts an already-normalized 254 number", () => { + expect(normalizeKenyanPhone("254712345678")).toBe("254712345678"); + }); + + it("strips spaces and the plus from a +254 number", () => { + expect(normalizeKenyanPhone("+254 712 345 678")).toBe("254712345678"); + }); + + it("normalizes a +254 number that also includes the trunk 0", () => { + expect(normalizeKenyanPhone("+254 0712 345 678")).toBe("254712345678"); + }); + + it("returns an empty string for an unrecognized number", () => { + expect(normalizeKenyanPhone("12345")).toBe(""); + }); +}); + +describe("isValidKenyanPhone", () => { + it("is true for a normalizable number", () => { + expect(isValidKenyanPhone("0712345678")).toBe(true); + }); + + it("is false for an unrecognized number", () => { + expect(isValidKenyanPhone("12345")).toBe(false); + }); +}); diff --git a/src/lib/utils/mpesa.ts b/src/lib/utils/mpesa.ts index cf90f26e..f68ca257 100644 --- a/src/lib/utils/mpesa.ts +++ b/src/lib/utils/mpesa.ts @@ -13,6 +13,9 @@ export function normalizeKenyanPhone(raw: string): string { if (/^[17]\d{8}$/.test(digits)) { return `254${digits}`; } + if (/^2540[17]\d{8}$/.test(digits)) { + return `254${digits.slice(4)}`; + } if (/^254[17]\d{8}$/.test(digits)) { return digits; } From c40af3b6cda45c8f906cbeea011e14da4eec0d83 Mon Sep 17 00:00:00 2001 From: Elias Date: Tue, 30 Jun 2026 12:12:14 +0300 Subject: [PATCH 05/12] fix(checkout): make M-Pesa timeout screen honest about payment status On poll timeout the screen sent the customer to /order-placed, a success page that says 'Thanks for your order' and 'a confirmation email has been sent'. For an unconfirmed M-Pesa payment that is misleading, since the payment may still be pending or may never arrive. The timeout screen now states the truth: the order is placed and the M-Pesa payment can take a few minutes to confirm. The button is changed from a misleading 'Check order status' link to the success page into a plain 'Continue Shopping' link to the store home, which works for both guest and logged-in shoppers (the account order page is login-gated and would trap guests). Copy avoids promising a payment confirmation email because no such email is sent. Updated across all five locales." --- messages/de.json | 6 +++--- messages/en.json | 6 +++--- messages/es.json | 6 +++--- messages/fr.json | 6 +++--- messages/pl.json | 6 +++--- .../[locale]/(checkout)/awaiting-payment/[id]/page.tsx | 4 ++-- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/messages/de.json b/messages/de.json index 00346e9c..ab93bc99 100644 --- a/messages/de.json +++ b/messages/de.json @@ -238,9 +238,9 @@ "mpesaPhoneInvalid": "Geben Sie eine gültige Safaricom-Nummer ein, z. B. 0712 345 678.", "mpesaAwaitTitle": "Prüfen Sie Ihr Telefon", "mpesaAwaitBody": "Wir haben eine M-Pesa-Zahlungsaufforderung an Ihr Telefon gesendet. Geben Sie Ihre M-Pesa-PIN ein, um die Zahlung abzuschließen.", - "mpesaTimeoutTitle": "Warten weiterhin auf Bestätigung", - "mpesaTimeoutBody": "Wir haben Ihre M-Pesa-Bestätigung noch nicht erhalten. Wenn Sie Ihre PIN bereits eingegeben haben, kann die Verarbeitung einen Moment dauern. Sie können den Bestellstatus unten prüfen.", - "mpesaCheckOrderStatus": "Bestellstatus prüfen", + "mpesaTimeoutTitle": "Zahlung noch nicht bestätigt", + "mpesaTimeoutBody": "Ihre Bestellung wurde aufgegeben. Wenn Sie Ihre M-Pesa-PIN bereits eingegeben haben, kann die Bestätigung einige Minuten dauern. Sie können diese Seite gefahrlos schließen.", + "mpesaContinueShopping": "Weiter einkaufen", "mpesaPaymentFailed": "Die M-Pesa-Zahlung wurde nicht abgeschlossen. Bitte versuchen Sie es erneut.", "failedToCreatePayment": "Zahlung konnte nicht erstellt werden. Bitte versuchen Sie es erneut.", "placeOrder": "Bestellung aufgeben", diff --git a/messages/en.json b/messages/en.json index e858efd5..ec650ba1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -238,9 +238,9 @@ "mpesaPhoneInvalid": "Enter a valid Safaricom number, e.g. 0712 345 678.", "mpesaAwaitTitle": "Check your phone", "mpesaAwaitBody": "We've sent an M-Pesa payment request to your phone. Enter your M-Pesa PIN to complete the payment.", - "mpesaTimeoutTitle": "Still waiting for confirmation", - "mpesaTimeoutBody": "We haven't received your M-Pesa confirmation yet. If you already entered your PIN, it may take a moment to settle. You can check your order status below.", - "mpesaCheckOrderStatus": "Check order status", + "mpesaTimeoutTitle": "Payment not yet confirmed", + "mpesaTimeoutBody": "Your order has been placed. If you already entered your M-Pesa PIN, it can take a few minutes to confirm. You can close this page safely.", + "mpesaContinueShopping": "Continue Shopping", "mpesaPaymentFailed": "The M-Pesa payment was not completed. Please try again.", "failedToCreatePayment": "Failed to create payment. Please try again.", "placeOrder": "Place Order", diff --git a/messages/es.json b/messages/es.json index c269b5e5..dcdf2730 100644 --- a/messages/es.json +++ b/messages/es.json @@ -238,9 +238,9 @@ "mpesaPhoneInvalid": "Ingresa un número de Safaricom válido, p. ej. 0712 345 678.", "mpesaAwaitTitle": "Revisa tu teléfono", "mpesaAwaitBody": "Hemos enviado una solicitud de pago M-Pesa a tu teléfono. Ingresa tu PIN de M-Pesa para completar el pago.", - "mpesaTimeoutTitle": "Esperando confirmación", - "mpesaTimeoutBody": "Aún no hemos recibido tu confirmación de M-Pesa. Si ya ingresaste tu PIN, puede tardar un momento en procesarse. Puedes revisar el estado de tu pedido abajo.", - "mpesaCheckOrderStatus": "Ver estado del pedido", + "mpesaTimeoutTitle": "Pago aún no confirmado", + "mpesaTimeoutBody": "Tu pedido se ha realizado. Si ya ingresaste tu PIN de M-Pesa, la confirmación puede tardar unos minutos. Puedes cerrar esta página de forma segura.", + "mpesaContinueShopping": "Seguir comprando", "mpesaPaymentFailed": "El pago de M-Pesa no se completó. Inténtalo de nuevo.", "failedToCreatePayment": "No se pudo crear el pago. Inténtalo de nuevo.", "placeOrder": "Realizar pedido", diff --git a/messages/fr.json b/messages/fr.json index c704d786..32164352 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -238,9 +238,9 @@ "mpesaPhoneInvalid": "Saisissez un numéro Safaricom valide, par ex. 0712 345 678.", "mpesaAwaitTitle": "Vérifiez votre téléphone", "mpesaAwaitBody": "Nous avons envoyé une demande de paiement M-Pesa sur votre téléphone. Saisissez votre code PIN M-Pesa pour finaliser le paiement.", - "mpesaTimeoutTitle": "En attente de confirmation", - "mpesaTimeoutBody": "Nous n'avons pas encore reçu votre confirmation M-Pesa. Si vous avez déjà saisi votre code PIN, le traitement peut prendre un moment. Vous pouvez vérifier l'état de votre commande ci-dessous.", - "mpesaCheckOrderStatus": "Vérifier l'état de la commande", + "mpesaTimeoutTitle": "Paiement pas encore confirmé", + "mpesaTimeoutBody": "Votre commande a été passée. Si vous avez déjà saisi votre code PIN M-Pesa, la confirmation peut prendre quelques minutes. Vous pouvez fermer cette page en toute sécurité.", + "mpesaContinueShopping": "Continuer mes achats", "mpesaPaymentFailed": "Le paiement M-Pesa n'a pas abouti. Veuillez réessayer.", "failedToCreatePayment": "Échec de la création du paiement. Veuillez réessayer.", "placeOrder": "Passer la commande", diff --git a/messages/pl.json b/messages/pl.json index ce779c26..77532d85 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -238,9 +238,9 @@ "mpesaPhoneInvalid": "Wprowadź prawidłowy numer Safaricom, np. 0712 345 678.", "mpesaAwaitTitle": "Sprawdź telefon", "mpesaAwaitBody": "Wysłaliśmy prośbę o płatność M-Pesa na Twój telefon. Wprowadź swój PIN M-Pesa, aby dokończyć płatność.", - "mpesaTimeoutTitle": "Wciąż czekamy na potwierdzenie", - "mpesaTimeoutBody": "Nie otrzymaliśmy jeszcze potwierdzenia M-Pesa. Jeśli już wprowadziłeś PIN, przetworzenie może chwilę potrwać. Status zamówienia możesz sprawdzić poniżej.", - "mpesaCheckOrderStatus": "Sprawdź status zamówienia", + "mpesaTimeoutTitle": "Płatność jeszcze niepotwierdzona", + "mpesaTimeoutBody": "Twoje zamówienie zostało złożone. Jeśli wprowadziłeś już PIN M-Pesa, potwierdzenie może potrwać kilka minut. Możesz bezpiecznie zamknąć tę stronę.", + "mpesaContinueShopping": "Kontynuuj zakupy", "mpesaPaymentFailed": "Płatność M-Pesa nie została zrealizowana. Spróbuj ponownie.", "failedToCreatePayment": "Nie udało się utworzyć płatności. Spróbuj ponownie.", "placeOrder": "Złóż zamówienie", diff --git a/src/app/[country]/[locale]/(checkout)/awaiting-payment/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/awaiting-payment/[id]/page.tsx index 7cde2230..b9e909ce 100644 --- a/src/app/[country]/[locale]/(checkout)/awaiting-payment/[id]/page.tsx +++ b/src/app/[country]/[locale]/(checkout)/awaiting-payment/[id]/page.tsx @@ -84,10 +84,10 @@ export default function AwaitingPaymentPage({

); From d28f5fc6c7be410546ec995e4602526f8924bdf7 Mon Sep 17 00:00:00 2001 From: Elias Date: Tue, 30 Jun 2026 12:45:38 +0300 Subject: [PATCH 06/12] refactor(checkout): use base field and button components for M-Pesa UI The M-Pesa payment block and the awaiting-payment timeout screen used hand-rolled markup instead of the shared design system. The phone input carried a raw label, a hand-tuned hint paragraph, and an error paragraph in text-red-700, which is off the design palette. The timeout screen used a raw button styled with inline gray classes. Replace these with the existing base components so the checkout matches the convention already used by the account forms: - label, hint, and error now use Field, FieldLabel, FieldDescription, and FieldError. FieldError renders text-destructive with role="alert", so the error colour comes from the design tokens and is announced to assistive tech. - the timeout action now uses Button. --- .../(checkout)/awaiting-payment/[id]/page.tsx | 7 +- src/components/checkout/PaymentSection.tsx | 64 +++++++++---------- 2 files changed, 36 insertions(+), 35 deletions(-) diff --git a/src/app/[country]/[locale]/(checkout)/awaiting-payment/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/awaiting-payment/[id]/page.tsx index b9e909ce..3005fdfb 100644 --- a/src/app/[country]/[locale]/(checkout)/awaiting-payment/[id]/page.tsx +++ b/src/app/[country]/[locale]/(checkout)/awaiting-payment/[id]/page.tsx @@ -4,6 +4,7 @@ import { CircleAlert, Loader2, Smartphone } from "lucide-react"; import { usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { use, useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; import { getOrderPaymentStatus } from "@/lib/data/payment"; import { extractBasePath } from "@/lib/utils/path"; @@ -82,13 +83,13 @@ export default function AwaitingPaymentPage({

{t("mpesaTimeoutBody")}

- +
); } diff --git a/src/components/checkout/PaymentSection.tsx b/src/components/checkout/PaymentSection.tsx index 1a6ff81b..938844b4 100644 --- a/src/components/checkout/PaymentSection.tsx +++ b/src/components/checkout/PaymentSection.tsx @@ -36,6 +36,13 @@ import { type StripePaymentFormHandle, } from "@/components/checkout/StripePaymentForm"; import { Checkbox } from "@/components/ui/checkbox"; +import { + Field, + FieldDescription, + FieldError, + FieldLabel, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { useCountryStates } from "@/hooks/useCountryStates"; import { getCreditCards } from "@/lib/data/credit-cards"; @@ -965,38 +972,31 @@ export function PaymentSection({ {pm.description}

)} - - { - setMpesaPhone(e.target.value); - if (mpesaPhoneError) setMpesaPhoneError(null); - }} - placeholder={t("mpesaPhonePlaceholder")} - className={`w-full rounded-sm border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 ${ - mpesaPhoneError ? "border-red-300" : "border-gray-300" - }`} - /> - {mpesaPhoneError ? ( -

- - {mpesaPhoneError} -

- ) : ( -

- {t("mpesaPhoneHint")} -

- )} + + + {t("mpesaPhoneLabel")} + + { + setMpesaPhone(e.target.value); + if (mpesaPhoneError) setMpesaPhoneError(null); + }} + placeholder={t("mpesaPhonePlaceholder")} + /> + {mpesaPhoneError ? ( + {mpesaPhoneError} + ) : ( + + {t("mpesaPhoneHint")} + + )} +
) : ( /* ── Direct/manual payment ── */ From 0e15f65bfe768d0ab60675bc59baf325625950f3 Mon Sep 17 00:00:00 2001 From: Elias Date: Thu, 2 Jul 2026 07:45:14 +0300 Subject: [PATCH 07/12] fix(homepage): read tenant homepage from design.layout.homepage extractHomepageSource returned the whole tenant record before descending into design.layout.homepage, because the "looks like a homepage config" guard passes when version and sections are absent (always true for the store record). Published homepages were never read, so every shop fell back to the default. Resolve the nested homepage first, keep the whole-record check as a last resort. Also allowlist the local media host for next/image so the hero image renders in dev. --- next.config.ts | 6 ++++++ src/lib/homepage/index.ts | 16 +++++++--------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/next.config.ts b/next.config.ts index 5360decd..852075df 100644 --- a/next.config.ts +++ b/next.config.ts @@ -42,6 +42,12 @@ const nextConfig: NextConfig = { hostname: "localhost", pathname: "/rails/active_storage/**", }, + { + protocol: "http", + hostname: "127.0.0.1", + port: "9000", + pathname: "/media/**", + }, { protocol: "https", hostname: "**.vendo.dev", diff --git a/src/lib/homepage/index.ts b/src/lib/homepage/index.ts index 0a8da02f..5baf0623 100644 --- a/src/lib/homepage/index.ts +++ b/src/lib/homepage/index.ts @@ -104,26 +104,24 @@ function extractHomepageSource(source: unknown): unknown { return source; } - if (isHomepageConfigLike(source)) { - return source; - } - const homepage = source.homepage; if (isHomepageConfigLike(homepage)) { return homepage; } const design = source.design; - if (!isRecord(design)) { - return source; + if (isRecord(design) && isRecord(design.layout)) { + const layoutHomepage = design.layout.homepage; + if (isRecord(layoutHomepage)) { + return layoutHomepage; + } } - const layout = design.layout; - if (!isRecord(layout)) { + if (isHomepageConfigLike(source)) { return source; } - return layout.homepage; + return source; } export function getDefaultHomepageConfig( From ecfccca9eb5a8615ad5b2be252fb7d63568f5892 Mon Sep 17 00:00:00 2001 From: Elias Date: Thu, 2 Jul 2026 07:46:49 +0300 Subject: [PATCH 08/12] feat(branding): default to OLITT logo and footer attribution Replace the Spree fallback logo with the OLITT logo and change the footer to "Powered by OLITT" (links olitt.com). --- public/olitt-logo.svg | 11 +++++++++++ src/components/layout/Footer.tsx | 8 ++++---- src/components/layout/Header.tsx | 4 ++-- 3 files changed, 17 insertions(+), 6 deletions(-) create mode 100644 public/olitt-logo.svg diff --git a/public/olitt-logo.svg b/public/olitt-logo.svg new file mode 100644 index 00000000..0e8c2c4e --- /dev/null +++ b/public/olitt-logo.svg @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index c058a457..e6fd408f 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -159,13 +159,13 @@ export async function Footer({

© {new Date().getFullYear()} {branding.name}. {t("poweredBy")} - Spree Commerce - {" "} - & Next.js. + OLITT + + .

diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index aea9b5c0..13105ec3 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -53,7 +53,7 @@ export async function Header({ const t = await getTranslations({ locale, namespace: "header" }); const branding = resolveTenantBranding(tenantConfig, { name: getStoreName(), - logoUrl: "/spree.png", + logoUrl: "/olitt-logo.svg", }); const navigation = resolveTenantNavigation(tenantConfig); @@ -66,7 +66,7 @@ export async function Header({ center={ {branding.name Date: Thu, 2 Jul 2026 10:05:07 +0300 Subject: [PATCH 09/12] test(homepage): guard tenant homepage resolution and section merge Cover getHomepageConfig reading the tenant homepage nested at design.layout.homepage, cleared social proof not leaking through the default merge, section order preservation, and the default fallback when no homepage is present. Fails on the pre-fix resolver ordering. --- src/lib/homepage/index.test.ts | 73 ++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/lib/homepage/index.test.ts diff --git a/src/lib/homepage/index.test.ts b/src/lib/homepage/index.test.ts new file mode 100644 index 00000000..d9de8225 --- /dev/null +++ b/src/lib/homepage/index.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { getHomepageConfig } from "./index"; + +const variables = { storeName: "Nairobi Sourdough" }; + +function tenantSource() { + return { + name: "Nairobi Sourdough", + design: { + layout: { + homepage: { + version: 1, + sections: [ + { + type: "hero", + title: "Fresh Sourdough", + description: "Baked daily in Nairobi", + badge: "", + stats: [], + media: { + imageUrl: "https://s3.test/product.jpg", + alt: "Fresh Sourdough", + floatingBadgeTitle: "", + floatingBadgeLabel: "", + secondaryBadgeTitle: "", + secondaryBadgeLabel: "", + }, + }, + { type: "features" }, + { type: "featured-products" }, + ], + }, + }, + }, + }; +} + +describe("getHomepageConfig", () => { + it("reads the tenant homepage nested at design.layout.homepage", () => { + const config = getHomepageConfig(variables, tenantSource()); + const hero = config.sections.find((section) => section.type === "hero"); + + expect(hero?.title).toBe("Fresh Sourdough"); + expect(hero?.stats).toEqual([]); + expect(hero?.media?.imageUrl).toBe("https://s3.test/product.jpg"); + }); + + it("does not leak default social proof when the tenant clears it", () => { + const config = getHomepageConfig(variables, tenantSource()); + const hero = config.sections.find((section) => section.type === "hero"); + const statValues = (hero?.stats ?? []).map((stat) => stat.value); + + expect(statValues).not.toContain("50k+"); + expect(hero?.media?.floatingBadgeLabel).not.toBe("To Los Angeles, CA"); + }); + + it("keeps every tenant section type in order", () => { + const config = getHomepageConfig(variables, tenantSource()); + + expect(config.sections.map((section) => section.type)).toEqual([ + "hero", + "features", + "featured-products", + ]); + }); + + it("falls back to the interpolated default when no homepage is present", () => { + const config = getHomepageConfig(variables, { name: "Nairobi Sourdough" }); + const hero = config.sections.find((section) => section.type === "hero"); + + expect(hero?.title).toBe("Nairobi Sourdough curated for modern living"); + }); +}); From 91b5ed19062c11813d5f420e7ceca2b2524719e8 Mon Sep 17 00:00:00 2001 From: Elias Date: Thu, 2 Jul 2026 10:14:59 +0300 Subject: [PATCH 10/12] fix(footer): address review on OLITT attribution and link safety Add rel="noopener noreferrer" to the OLITT footer link to match the file's other external links, insert an explicit space so "Powered by" renders separately from "OLITT" across all locales, and drop the redundant homepage-source self-check that returned source on both branches. --- src/components/layout/Footer.tsx | 3 ++- src/lib/homepage/index.ts | 4 ---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index e6fd408f..ba883e14 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -157,10 +157,11 @@ export async function Footer({

- © {new Date().getFullYear()} {branding.name}. {t("poweredBy")} + © {new Date().getFullYear()} {branding.name}. {t("poweredBy")}{" "} OLITT diff --git a/src/lib/homepage/index.ts b/src/lib/homepage/index.ts index 5baf0623..a4d1daab 100644 --- a/src/lib/homepage/index.ts +++ b/src/lib/homepage/index.ts @@ -117,10 +117,6 @@ function extractHomepageSource(source: unknown): unknown { } } - if (isHomepageConfigLike(source)) { - return source; - } - return source; } From c9f4df2bb59ef17a73fd9eeb56e7bfd7bd8e504e Mon Sep 17 00:00:00 2001 From: Elias Date: Thu, 2 Jul 2026 17:07:57 +0300 Subject: [PATCH 11/12] fix(branding): render the OLITT SVG logo in the header The branding default introduced an SVG logo that never painted: next/image returned 400 because dangerouslyAllowSVG was unset, and once SVGs are allowed it strips their intrinsic width/height while the header forced the img to height:auto, collapsing it to 0x0. Allow SVGs with a locked-down CSP and pin the logo to a fixed height so the browser derives width from the viewBox. --- next.config.ts | 2 ++ src/components/layout/Header.tsx | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/next.config.ts b/next.config.ts index 852075df..b2a69192 100644 --- a/next.config.ts +++ b/next.config.ts @@ -33,6 +33,8 @@ const nextConfig: NextConfig = { }, images: { qualities: [25, 50, 75, 85, 100], + dangerouslyAllowSVG: true, + contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;", dangerouslyAllowLocalIP: true, // Allow localhost images in development deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 13105ec3..9f437c17 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -71,7 +71,7 @@ export async function Header({ width={90} height={32} className="max-w-full object-contain" - style={{ width: "auto", height: "auto" }} + style={{ width: "auto", height: "32px" }} fetchPriority="high" loading="eager" /> From 8ef2987a96ba694ca7a0b082992dc9ff7b28adc0 Mon Sep 17 00:00:00 2001 From: Elias Date: Fri, 3 Jul 2026 09:54:00 +0300 Subject: [PATCH 12/12] fix(next-image): add olitt.com and olitt.net to next/image remote patterns --- next.config.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/next.config.ts b/next.config.ts index b2a69192..68c27a6a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -75,6 +75,16 @@ const nextConfig: NextConfig = { hostname: "**.olitt.store", pathname: "/rails/active_storage/**", }, + { + protocol: "https", + hostname: "**.olitt.com", + pathname: "/media/**", + }, + { + protocol: "https", + hostname: "**.olitt.net", + pathname: "/media/**", + }, { protocol: "https", hostname: "images.unsplash.com",