Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/storefront/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { useAuthHeartbeat, useAuthenticatedAccountId } from "./lib/auth";
import { useSyncCartToBackend } from "./lib/backendCart";
import { readingListRemote, wishlistRemote } from "./lib/savedLists";
import type { StorefrontRouteKey } from "./lib/storefrontPaths";
import { matchesStorefrontFallbackPath } from "./lib/routePathMatching";
import { useResolvedStorefrontLanguageRoute, useResolvedStorefrontPath } from "./lib/storefrontPaths";
import { applyLayoutConfiguration, useLayoutPreferencesFromBackendConfig } from "./lib/layoutPreferencesSync";
import { CreatorContentProvider } from "./state/creatorContent";
Expand Down Expand Up @@ -485,7 +486,7 @@ function StorefrontRedirectRoute({

useEffect(() => {
if (isLoading || resolvedPath === currentPath) return;
if (currentPath === normalizeBrowserPath(fallback)) {
if (matchesStorefrontFallbackPath(currentPath, fallback, routeLanguage)) {
navigate(`${path}${location.search}${location.hash}`, { replace: true });
}
}, [currentPath, fallback, isLoading, location.hash, location.search, navigate, path, resolvedPath]);
Expand Down
91 changes: 69 additions & 22 deletions apps/storefront/src/lib/checkout.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
/** Checkout utilities — shipping, taxes, coupons, and related calculations. */

import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { CartLineItem } from "@funky/ui";
import { syncCartToBackend } from "./backendCart";
import { isBackendConfigured } from "@funky/sdk";
import {
applyCoupon,
calculateTaxes,
getCart,
getShippingMethods,
removeCoupon,
selectShippingMethod,
Expand Down Expand Up @@ -58,69 +57,117 @@ export function useCheckoutCart(
cart: StoreApiCart | null;
loading: boolean;
error: string | null;
}>({ cart: null, loading: false, error: null });
syncedCartRevision: string | null;
}>({ cart: null, loading: false, error: null, syncedCartRevision: null });
const addressKey = [billingAddress, shippingAddress]
.map((address) => address
? [
address.first_name,
address.last_name,
address.address_1,
address.address_2 ?? "",
address.city,
address.state ?? "",
address.postcode,
address.country,
address.email ?? "",
address.phone ?? "",
].join("|")
: "")
.concat(cartRevision)
.join("::");
const latestAddressesRef = useRef({ billingAddress, shippingAddress });
latestAddressesRef.current = { billingAddress, shippingAddress };
const lastRequestedAddressKeyRef = useRef(addressKey);

useEffect(() => {
if (!isBackendConfigured || !billingAddress || !shippingAddress) {
setState({ cart: null, loading: false, error: null });
setState({ cart: null, loading: false, error: null, syncedCartRevision: null });
return;
}

let cancelled = false;
setState((previous) => ({ ...previous, loading: true, error: null }));
lastRequestedAddressKeyRef.current = addressKey;
setState((previous) => ({
...previous,
loading: true,
error: null,
syncedCartRevision: null,
}));
void syncCartToBackend(frontendCart, {
force: true,
verifyForCheckout: true,
ignoreSuspension: true,
}).then(async (syncResult) => {
if (cancelled) return;
if (!syncResult.ok) {
setState({ cart: null, loading: false, error: syncResult.error });
setState({
cart: null,
loading: false,
error: syncResult.error,
syncedCartRevision: null,
});
return;
}
const latestAddresses = latestAddressesRef.current;
if (!latestAddresses.billingAddress || !latestAddresses.shippingAddress) {
setState({ cart: null, loading: false, error: null, syncedCartRevision: null });
return;
}
const result = await updateCartCustomer(billingAddress, shippingAddress);
const result = await updateCartCustomer(
latestAddresses.billingAddress,
latestAddresses.shippingAddress,
);
if (cancelled) return;
if (!result.ok) {
setState({ cart: null, loading: false, error: result.error });
setState({
cart: null,
loading: false,
error: result.error,
syncedCartRevision: null,
});
return;
}
const refreshed = await getCart();
if (cancelled) return;
setState(refreshed.ok
? { cart: refreshed.data, loading: false, error: null }
: { cart: result.data, loading: false, error: refreshed.error });
setState({
cart: result.data,
loading: false,
error: null,
syncedCartRevision: cartRevision,
});
});
return () => {
cancelled = true;
};
}, [addressKey]);
}, [cartRevision]);

useEffect(() => {
if (!isBackendConfigured || !billingAddress || !shippingAddress) {
lastRequestedAddressKeyRef.current = addressKey;
return;
}
if (lastRequestedAddressKeyRef.current === addressKey) return;

lastRequestedAddressKeyRef.current = addressKey;
let cancelled = false;
setState((previous) => ({ ...previous, loading: true, error: null }));
const timeoutId = window.setTimeout(() => {
void updateCartCustomer(billingAddress, shippingAddress).then((result) => {
if (cancelled) return;
setState((previous) => result.ok
? { ...previous, cart: result.data, loading: false, error: null }
: { ...previous, loading: false, error: result.error });
});
}, 300);

return () => {
cancelled = true;
window.clearTimeout(timeoutId);
};
}, [addressKey, cartRevision]);

const adoptCart = useCallback((cart: StoreApiCart) => {
setState({ cart, loading: false, error: null });
setState((previous) => ({ ...previous, cart, loading: false, error: null }));
}, []);

const selectMethod = useCallback(async (packageId: number, rateId: string) => {
setState((previous) => ({ ...previous, loading: true, error: null }));
const result = await selectShippingMethod({ package_id: packageId, rate_id: rateId });
if (result.ok) {
setState({ cart: result.data, loading: false, error: null });
setState((previous) => ({ ...previous, cart: result.data, loading: false, error: null }));
} else {
setState((previous) => ({ ...previous, loading: false, error: result.error }));
}
Expand Down
50 changes: 40 additions & 10 deletions apps/storefront/src/lib/checkoutContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ test("builds the Store API checkout language and attribution bridge", () => {
referrer: "https://example.com/campaign",
user_agent: "Storefront test browser",
session_start_time: "2026-08-06T01:00:00.000Z",
digital_order: false,
},
},
);
Expand Down Expand Up @@ -99,7 +100,7 @@ test("checkout payload preserves language, order notes, and a different shipping
assert.equal(payload.customer_password, "correct-horse-battery-staple");
});

test("digital checkout supplies required Store API and Stripe address placeholders", () => {
test("digital checkout supplies a country-valid Store API and Stripe fallback address", () => {
const billing = {
firstName: "Ada",
lastName: "Lovelace",
Expand All @@ -121,17 +122,46 @@ test("digital checkout supplies required Store API and Stripe address placeholde
);
const stripeBilling = toStripeBillingDetails(paymentBilling);

assert.equal(payload.billing_address.address_1, "Digital delivery");
assert.equal(payload.billing_address.city, "Digital order");
assert.equal(payload.billing_address.postcode, "00000");
assert.equal(payload.billing_address.address_1, "Dostawa cyfrowa 1");
assert.equal(payload.billing_address.city, "Warszawa");
assert.equal(payload.billing_address.state, "MZ");
assert.equal(payload.billing_address.postcode, "00-001");
assert.equal(payload.billing_address.country, "PL");
assert.deepEqual(payload.shipping_address, payload.billing_address);
assert.equal(stripePaymentData.get("billing_address_1"), "Digital delivery");
assert.equal(stripePaymentData.get("billing_city"), "Digital order");
assert.equal(stripePaymentData.get("billing_postcode"), "00000");
assert.equal(stripeBilling.address.line1, "Digital delivery");
assert.equal(stripeBilling.address.city, "Digital order");
assert.equal(stripeBilling.address.postal_code, "00000");
assert.equal(payload.extensions?.[CHECKOUT_CONTEXT_NAMESPACE].digital_order, true);
assert.equal(stripePaymentData.get("billing_address_1"), "Dostawa cyfrowa 1");
assert.equal(stripePaymentData.get("billing_city"), "Warszawa");
assert.equal(stripePaymentData.get("billing_state"), "MZ");
assert.equal(stripePaymentData.get("billing_postcode"), "00-001");
assert.equal(stripeBilling.address.line1, "Dostawa cyfrowa 1");
assert.equal(stripeBilling.address.city, "Warszawa");
assert.equal(stripeBilling.address.state, "MZ");
assert.equal(stripeBilling.address.postal_code, "00-001");
});

test("digital checkout uses valid state and postcode formats for supported countries", () => {
const countries = [
{ countryCode: "DE", state: "DE-BE", postcode: "10115" },
{ countryCode: "FR", state: "75", postcode: "75001" },
{ countryCode: "GB", state: "London", postcode: "SW1A 1AA" },
{ countryCode: "NL", state: "NH", postcode: "1011 AA" },
{ countryCode: "US", state: "CA", postcode: "94105" },
];

for (const country of countries) {
const address = withDigitalCheckoutAddress({
firstName: "Ada",
lastName: "Lovelace",
addressLine1: "",
city: "",
postcode: "",
countryCode: country.countryCode,
email: "ada@example.com",
phone: "+48 123 456 789",
});
assert.equal(address.state, country.state);
assert.equal(address.postcode, country.postcode);
}
});

test("physical checkout does not invent missing address fields", () => {
Expand Down
46 changes: 35 additions & 11 deletions apps/storefront/src/lib/checkoutContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type CheckoutContextInput = {
referrer?: string;
userAgent?: string;
sessionStartTime?: string;
digitalOrder?: boolean;
};

function trimmed(value: string | undefined, maxLength: number): string {
Expand Down Expand Up @@ -47,6 +48,7 @@ export function buildCheckoutExtensions(
referrer: trimmed(context.referrer, 500),
user_agent: trimmed(context.userAgent, 500),
session_start_time: trimmed(context.sessionStartTime, 40),
digital_order: context.digitalOrder === true,
},
};
}
Expand All @@ -67,29 +69,50 @@ function toStoreApiAddress(details: CheckoutBillingDetails): StoreApiAddress {
};
}

const DIGITAL_ADDRESS_FALLBACK = {
addressLine1: "Digital delivery",
city: "Digital order",
postcode: "00000",
} as const;
const DIGITAL_ADDRESS_FALLBACKS: Record<string, {
addressLine1: string;
city: string;
state: string;
postcode: string;
}> = {
DE: { addressLine1: "Digital delivery 1", city: "Berlin", state: "DE-BE", postcode: "10115" },
FR: { addressLine1: "1 Livraison numerique", city: "Paris", state: "75", postcode: "75001" },
GB: { addressLine1: "1 Digital Delivery", city: "London", state: "London", postcode: "SW1A 1AA" },
NL: { addressLine1: "Digital delivery 1", city: "Amsterdam", state: "NH", postcode: "1011 AA" },
PL: { addressLine1: "Dostawa cyfrowa 1", city: "Warszawa", state: "MZ", postcode: "00-001" },
US: { addressLine1: "1 Digital Delivery", city: "San Francisco", state: "CA", postcode: "94105" },
};

function digitalAddressFallback(countryCode: string) {
return DIGITAL_ADDRESS_FALLBACKS[countryCode.trim().toUpperCase()] ?? {
addressLine1: "Digital delivery 1",
city: "Digital order",
state: "Digital order",
postcode: "00000",
};
}

export function withDigitalCheckoutAddress(
details: CheckoutBillingDetails,
): CheckoutBillingDetails {
const fallback = digitalAddressFallback(details.countryCode);
return {
...details,
addressLine1: details.addressLine1.trim() || DIGITAL_ADDRESS_FALLBACK.addressLine1,
city: details.city.trim() || DIGITAL_ADDRESS_FALLBACK.city,
postcode: details.postcode.trim() || DIGITAL_ADDRESS_FALLBACK.postcode,
addressLine1: details.addressLine1.trim() || fallback.addressLine1,
city: details.city.trim() || fallback.city,
state: details.state?.trim() || fallback.state,
postcode: details.postcode.trim() || fallback.postcode,
};
}

export function withDigitalStoreApiAddress(address: StoreApiAddress): StoreApiAddress {
const fallback = digitalAddressFallback(address.country);
return {
...address,
address_1: address.address_1.trim() || DIGITAL_ADDRESS_FALLBACK.addressLine1,
city: address.city.trim() || DIGITAL_ADDRESS_FALLBACK.city,
postcode: address.postcode.trim() || DIGITAL_ADDRESS_FALLBACK.postcode,
address_1: address.address_1.trim() || fallback.addressLine1,
city: address.city.trim() || fallback.city,
state: address.state?.trim() || fallback.state,
postcode: address.postcode.trim() || fallback.postcode,
};
}

Expand Down Expand Up @@ -124,6 +147,7 @@ export function buildStoreCheckoutPayload(
referrer: typeof document !== "undefined" ? document.referrer : undefined,
userAgent: typeof navigator !== "undefined" ? navigator.userAgent : undefined,
sessionStartTime: new Date().toISOString(),
digitalOrder: options?.digitalOrder,
}),
payment_data:
stripePaymentData ??
Expand Down
4 changes: 4 additions & 0 deletions apps/storefront/src/lib/recentOrders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ test("recent orders preserve the visible order and interval across reload-style

const notifier = document.getElementById("storefront-recent-orders");
assert.ok(notifier);
assert.equal(notifier.tagName, "DIV");
assert.equal(notifier.getAttribute("role"), "status");
assert.equal(notifier.getAttribute("aria-live"), "polite");
assert.equal(notifier.getAttribute("aria-atomic"), "true");
assert.match(notifier.textContent || "", /Anna bought 2 × Gallery plugin/);
assert.equal(notifier.dataset.chatbotOffset, "false");
const productLink = notifier.querySelector<HTMLAnchorElement>(".storefront-recent-orders__product");
Expand Down
2 changes: 1 addition & 1 deletion apps/storefront/src/lib/recentOrders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ function syncChatbotOffset(element: HTMLElement) {

function createNotifierElement() {
document.getElementById("storefront-recent-orders")?.remove();
const element = document.createElement("aside");
const element = document.createElement("div");
element.id = "storefront-recent-orders";
element.className = "storefront-recent-orders";
element.setAttribute("role", "status");
Expand Down
13 changes: 13 additions & 0 deletions apps/storefront/src/lib/routePathMatching.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import assert from "node:assert/strict";
import test from "node:test";
import { matchesStorefrontFallbackPath } from "./routePathMatching.ts";

test("matches localized fallback routes regardless of trailing slash", () => {
assert.equal(matchesStorefrontFallbackPath("/order-success/", "/order-success"), true);
assert.equal(matchesStorefrontFallbackPath("/pl/order-success", "/order-success", "pl"), true);
assert.equal(matchesStorefrontFallbackPath("/pl/order-success/", "/order-success", "pl"), true);
});

test("does not treat a configured localized slug as the fallback route", () => {
assert.equal(matchesStorefrontFallbackPath("/pl/zamowienie-otrzymane/", "/order-success", "pl"), false);
});
18 changes: 18 additions & 0 deletions apps/storefront/src/lib/routePathMatching.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
function normalizePathname(pathname: string): string {
const withLeadingSlash = pathname.startsWith("/") ? pathname : `/${pathname}`;
if (withLeadingSlash === "/") return "/";
return withLeadingSlash.replace(/\/+$/, "");
}

export function matchesStorefrontFallbackPath(
pathname: string,
fallback: string,
routeLanguage?: string,
): boolean {
const normalizedPathname = normalizePathname(pathname);
const normalizedFallback = normalizePathname(fallback);
if (normalizedPathname === normalizedFallback) return true;
if (!routeLanguage) return false;

return normalizedPathname === normalizePathname(`/${routeLanguage}${normalizedFallback}`);
}
Loading
Loading