diff --git a/messages/de.json b/messages/de.json index 42646baf..ab93bc99 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": "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", "noPaymentRequired": "Keine Zahlung erforderlich — Ihre Bestellung ist vollständig abgedeckt.", diff --git a/messages/en.json b/messages/en.json index 01103cc4..ec650ba1 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": "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", "noPaymentRequired": "No payment required — your order is fully covered.", diff --git a/messages/es.json b/messages/es.json index 2ab5f75f..dcdf2730 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": "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", "noPaymentRequired": "No se requiere pago — tu pedido está completamente cubierto.", diff --git a/messages/fr.json b/messages/fr.json index a07f1713..32164352 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": "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", "noPaymentRequired": "Aucun paiement requis — votre commande est entièrement couverte.", diff --git a/messages/pl.json b/messages/pl.json index 388315c8..77532d85 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": "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", "noPaymentRequired": "Płatność nie jest wymagana — zamówienie jest w pełni pokryte.", diff --git a/next.config.ts b/next.config.ts index 5360decd..68c27a6a 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], @@ -42,6 +44,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", @@ -67,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", 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/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/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..3005fdfb --- /dev/null +++ b/src/app/[country]/[locale]/(checkout)/awaiting-payment/[id]/page.tsx @@ -0,0 +1,107 @@ +"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 { Button } from "@/components/ui/button"; +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) { + let state: "pending" | "completed" | "failed" = "pending"; + try { + ({ state } = await getOrderPaymentStatus(cartId)); + } catch { + state = "pending"; + } + 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..938844b4 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, @@ -35,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"; @@ -49,12 +57,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 +155,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 +581,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 +604,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 +628,7 @@ export function PaymentSection({ selectedCardId, useShippingForBilling, billAddress, + mpesaPhone, onUpdateBillingAddress, onPaymentComplete, cart.id, @@ -706,6 +740,16 @@ export function PaymentSection({ } ${index > 0 ? "border-t" : ""}`} > + {isMpesaMethod(pm.type) && ( + M-Pesa + )} {pm.name} @@ -717,6 +761,16 @@ export function PaymentSection({
+ {isMpesaMethod(pm.type) && ( + M-Pesa + )} {pm.name} @@ -910,6 +964,40 @@ export function PaymentSection({ } })()} + ) : isMpesaMethod(pm.type) ? ( + /* ── M-Pesa: explicit phone number ── */ +
+ {pm.description && ( +

+ {pm.description} +

+ )} + + + {t("mpesaPhoneLabel")} + + { + setMpesaPhone(e.target.value); + if (mpesaPhoneError) setMpesaPhoneError(null); + }} + placeholder={t("mpesaPhonePlaceholder")} + /> + {mpesaPhoneError ? ( + {mpesaPhoneError} + ) : ( + + {t("mpesaPhoneHint")} + + )} + +
) : ( /* ── Direct/manual payment ── */
diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index c058a457..ba883e14 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -157,15 +157,16 @@ export async function Footer({

- © {new Date().getFullYear()} {branding.name}. {t("poweredBy")} + © {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..9f437c17 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,12 +66,12 @@ export async function Header({ center={ {branding.name 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/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"); + }); +}); diff --git a/src/lib/homepage/index.ts b/src/lib/homepage/index.ts index 0a8da02f..a4d1daab 100644 --- a/src/lib/homepage/index.ts +++ b/src/lib/homepage/index.ts @@ -104,26 +104,20 @@ 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; - } - - const layout = design.layout; - if (!isRecord(layout)) { - return source; + if (isRecord(design) && isRecord(design.layout)) { + const layoutHomepage = design.layout.homepage; + if (isRecord(layoutHomepage)) { + return layoutHomepage; + } } - return layout.homepage; + return source; } export function getDefaultHomepageConfig( 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); } 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 new file mode 100644 index 00000000..f68ca257 --- /dev/null +++ b/src/lib/utils/mpesa.ts @@ -0,0 +1,27 @@ +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 (/^2540[17]\d{8}$/.test(digits)) { + return `254${digits.slice(4)}`; + } + if (/^254[17]\d{8}$/.test(digits)) { + return digits; + } + return ""; +} + +export function isValidKenyanPhone(raw: string): boolean { + return normalizeKenyanPhone(raw).length > 0; +}