diff --git a/package-lock.json b/package-lock.json index 565942e2..3c39a82d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,8 @@ "dependencies": { "@next/third-parties": "^16.1.6", "@sentry/nextjs": "^10.38.0", - "@spree/next": "^0.6.1", - "@spree/sdk": "^0.6.0", + "@spree/next": "^0.6.4", + "@spree/sdk": "^0.6.3", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", "next": "^16", @@ -4728,20 +4728,20 @@ } }, "node_modules/@spree/next": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@spree/next/-/next-0.6.1.tgz", - "integrity": "sha512-QrewJuyzKSmgNvgCh5+OHAp84LrKtEN2MuR1ZwjnIZZegEbHFPTuJOyAk0xUrbTPhezi8rOpB2iY4kfyGffPLA==", + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@spree/next/-/next-0.6.4.tgz", + "integrity": "sha512-myjCgIn37MVnuyQQUPAVZkxAb570M/IBfNV9WqNb7SNug8aejIBHz6kQ5BHMmliiWCQE2R5PtbDnviDRfhXP9A==", "license": "MIT", "peerDependencies": { - "@spree/sdk": ">=0.6.1", + "@spree/sdk": ">=0.6.3", "next": ">=15.0.0", "react": ">=19.0.0" } }, "node_modules/@spree/sdk": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@spree/sdk/-/sdk-0.6.1.tgz", - "integrity": "sha512-R6OoxH5moo2ZGKK6tCG96N2hyJgapEw61wAqFgNkI3Gogt60UT1r+ObXLXSNoqbRx/q2tnqRzGae6rKrYNKShA==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@spree/sdk/-/sdk-0.6.3.tgz", + "integrity": "sha512-Sjjiik30UtGIspdmcEeS71ZgdFTju4wpJNI3zOA1celXEvhM3+MqToaaFCi1L+DagiLR6WkDiQUgYcMT9/1Z4g==", "license": "MIT", "engines": { "node": ">=18.0.0" diff --git a/package.json b/package.json index af7ad649..56a7b51d 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,8 @@ "dependencies": { "@next/third-parties": "^16.1.6", "@sentry/nextjs": "^10.38.0", - "@spree/next": "^0.6.1", - "@spree/sdk": "^0.6.0", + "@spree/next": "^0.6.4", + "@spree/sdk": "^0.6.3", "@stripe/react-stripe-js": "^5.6.0", "@stripe/stripe-js": "^8.7.0", "next": "^16", diff --git a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx index fcc25d35..7fb56955 100644 --- a/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx +++ b/src/app/[country]/[locale]/(checkout)/checkout/[id]/page.tsx @@ -31,7 +31,8 @@ import { updateOrderAddresses, } from "@/lib/data/checkout"; import { isAuthenticated as checkAuth } from "@/lib/data/cookies"; -import { getCountries, getCountry } from "@/lib/data/countries"; +import { getCountry } from "@/lib/data/countries"; +import { getMarketCountries, resolveMarket } from "@/lib/data/markets"; import { completeCheckoutOrder, completeCheckoutPaymentSession, @@ -95,7 +96,7 @@ function CheckoutSidebar({ export default function CheckoutPage({ params }: CheckoutPageProps) { // use() must be called before all other hooks to avoid hook order issues - const { id: orderId } = use(params); + const { id: orderId, country: urlCountry } = use(params); const router = useRouter(); const pathname = usePathname(); const basePath = extractBasePath(pathname); @@ -176,19 +177,25 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { handleRemoveCoupon, ]); - // Load order and countries + // Load order and market-scoped countries const loadOrder = useCallback(async () => { setLoading(true); setError(null); try { - const [orderData, countriesData, addressesData, authStatus] = - await Promise.all([ - getCheckoutOrder(orderId), - getCountries(), - getAddresses(), - checkAuth(), - ]); + const [orderData, market, addressesData, authStatus] = await Promise.all([ + getCheckoutOrder(orderId), + resolveMarket(urlCountry).catch(() => null), + getAddresses(), + checkAuth(), + ]); + + // Fetch countries scoped to the resolved market + const countriesData = market + ? await getMarketCountries(market.id).catch(() => ({ + data: [] as StoreCountry[], + })) + : { data: [] as StoreCountry[] }; if (!orderData) { setError("Order not found or you don't have access to it."); @@ -233,7 +240,7 @@ export default function CheckoutPage({ params }: CheckoutPageProps) { } finally { setLoading(false); } - }, [orderId, basePath, router]); + }, [orderId, urlCountry, basePath, router]); useEffect(() => { loadOrder(); diff --git a/src/components/layout/CountrySwitcher.tsx b/src/components/layout/CountrySwitcher.tsx index 3c2b16e9..ce818989 100644 --- a/src/components/layout/CountrySwitcher.tsx +++ b/src/components/layout/CountrySwitcher.tsx @@ -1,10 +1,11 @@ "use client"; -import type { StoreCountry } from "@spree/sdk"; import { usePathname, useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { CheckIcon, ChevronDownIcon } from "@/components/icons"; -import { useStore } from "@/contexts/StoreContext"; +import { useCart } from "@/contexts/CartContext"; +import { type CountryWithMarket, useStore } from "@/contexts/StoreContext"; +import { updateOrderMarket } from "@/lib/data/checkout"; import { setStoreCookies } from "@/lib/utils/cookies"; import { getPathWithoutPrefix } from "@/lib/utils/path"; @@ -22,6 +23,7 @@ function countryToFlag(countryCode: string): string { export function CountrySwitcher() { const { country, currency, countries, setCountry, loading } = useStore(); + const { cart, refreshCart } = useCart(); const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); const router = useRouter(); @@ -42,12 +44,26 @@ export function CountrySwitcher() { return () => document.removeEventListener("mousedown", handleClickOutside); }, []); - // Handle country selection — derive locale and currency from the country directly - const handleCountrySelect = (entry: StoreCountry) => { + // Handle country selection — derive locale and currency from the country's market + const handleCountrySelect = async (entry: CountryWithMarket) => { const newLocale = entry.default_locale || "en"; + const newCurrency = entry.currency; const pathRest = getPathWithoutPrefix(pathname); const newPath = `/${entry.iso.toLowerCase()}/${newLocale}${pathRest}`; + // Update existing cart if currency or locale changed + if (cart && (cart.currency !== newCurrency || cart.locale !== newLocale)) { + const result = await updateOrderMarket(cart.id, { + currency: newCurrency, + locale: newLocale, + }); + if (!result.success) { + setIsOpen(false); + return; + } + await refreshCart(); + } + setStoreCookies(entry.iso.toLowerCase(), newLocale); setCountry(entry.iso.toLowerCase()); diff --git a/src/contexts/StoreContext.tsx b/src/contexts/StoreContext.tsx index b8b6ca8f..9beeff44 100644 --- a/src/contexts/StoreContext.tsx +++ b/src/contexts/StoreContext.tsx @@ -1,6 +1,6 @@ "use client"; -import type { StoreCountry, StoreStore } from "@spree/sdk"; +import type { StoreCountry, StoreMarket, StoreStore } from "@spree/sdk"; import { usePathname, useRouter } from "next/navigation"; import { createContext, @@ -12,17 +12,24 @@ import { useRef, useState, } from "react"; -import { getCountries as getCountriesAction } from "@/lib/data/countries"; +import { getMarkets as getMarketsAction } from "@/lib/data/markets"; import { getStore as getStoreAction } from "@/lib/data/store"; import { setStoreCookies } from "@/lib/utils/cookies"; import { getPathWithoutPrefix } from "@/lib/utils/path"; +/** Country enriched with market info (currency, locale, etc.) */ +export interface CountryWithMarket extends StoreCountry { + currency: string; + default_locale: string; + marketId: string | null; +} + interface StoreContextValue { country: string; locale: string; currency: string; store: StoreStore | null; - countries: StoreCountry[]; + countries: CountryWithMarket[]; setCountry: (country: string) => void; setLocale: (locale: string) => void; loading: boolean; @@ -36,22 +43,45 @@ interface StoreProviderProps { initialLocale: string; } +/** Build a flat country list from markets, enriching each country with market info. */ +function buildCountriesFromMarkets( + markets: StoreMarket[], +): CountryWithMarket[] { + const seen = new Set(); + const result: CountryWithMarket[] = []; + + for (const market of markets) { + for (const country of market.countries ?? []) { + if (seen.has(country.iso)) continue; + seen.add(country.iso); + + result.push({ + ...country, + currency: market.currency, + default_locale: market.default_locale, + marketId: market.id, + }); + } + } + + return result; +} + /** Find a country by ISO code in the flat countries list. */ function findCountry( - countries: StoreCountry[], + countries: CountryWithMarket[], countryIso: string, -): StoreCountry | undefined { +): CountryWithMarket | undefined { return countries.find( (c) => c.iso.toLowerCase() === countryIso.toLowerCase(), ); } function resolveCountryAndCurrency( - countries: StoreCountry[], - storeData: StoreStore, + countries: CountryWithMarket[], urlCountry: string, ): { - country: StoreCountry | undefined; + country: CountryWithMarket | undefined; currency: string; locale: string; needsRedirect: boolean; @@ -97,26 +127,26 @@ export function StoreProvider({ const [locale, setLocaleState] = useState(initialLocale); const [currency, setCurrency] = useState("USD"); const [store, setStore] = useState(null); - const [countries, setCountries] = useState([]); + const [countries, setCountries] = useState([]); const [loading, setLoading] = useState(true); const pathnameRef = useRef(pathname); pathnameRef.current = pathname; - // Fetch store and countries data on mount + // Fetch store and markets data on mount useEffect(() => { const fetchData = async () => { try { - const [storeData, countriesData] = await Promise.all([ + const [storeData, marketsData] = await Promise.all([ getStoreAction(), - getCountriesAction(), + getMarketsAction(), ]); setStore(storeData); - setCountries(countriesData.data); + const enrichedCountries = buildCountriesFromMarkets(marketsData.data); + setCountries(enrichedCountries); const resolved = resolveCountryAndCurrency( - countriesData.data, - storeData, + enrichedCountries, initialCountry, ); @@ -153,8 +183,9 @@ export function StoreProvider({ (newCountry: string): void => { setCountryState(newCountry); const countryObj = findCountry(countries, newCountry); - if (countryObj?.currency) { + if (countryObj) { setCurrency(countryObj.currency); + setLocaleState(countryObj.default_locale); } }, [countries], diff --git a/src/lib/data/__tests__/checkout.test.ts b/src/lib/data/__tests__/checkout.test.ts index 07dcc513..73c96d7f 100644 --- a/src/lib/data/__tests__/checkout.test.ts +++ b/src/lib/data/__tests__/checkout.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@spree/next", () => ({ getCheckout: vi.fn(), - updateAddresses: vi.fn(), + updateOrder: vi.fn(), advance: vi.fn(), getShipments: vi.fn(), selectShippingRate: vi.fn(), @@ -19,7 +19,7 @@ import { getShipments as getShipmentsSdk, removeCoupon, selectShippingRate as selectShippingRateSdk, - updateAddresses, + updateOrder, } from "@spree/next"; import { @@ -31,11 +31,12 @@ import { removeCouponCode, selectShippingRate, updateOrderAddresses, + updateOrderMarket, } from "@/lib/data/checkout"; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test fixtures are intentionally partial const mockGetCheckout = getCheckout as any; -const mockUpdateAddresses = updateAddresses as any; +const mockUpdateOrder = updateOrder as any; const mockAdvance = advance as any; const mockGetShipments = getShipmentsSdk as any; const mockSelectShippingRate = selectShippingRateSdk as any; @@ -72,17 +73,17 @@ describe("checkout server actions", () => { describe("updateOrderAddresses", () => { it("returns success with order", async () => { - mockUpdateAddresses.mockResolvedValue(mockOrder); + mockUpdateOrder.mockResolvedValue(mockOrder); const addresses = { email: "test@example.com" }; const result = await updateOrderAddresses("order-1", addresses); - expect(mockUpdateAddresses).toHaveBeenCalledWith("order-1", addresses); + expect(mockUpdateOrder).toHaveBeenCalledWith("order-1", addresses); expect(result).toEqual({ success: true, order: mockOrder }); }); it("returns error on failure", async () => { - mockUpdateAddresses.mockRejectedValue(new Error("Invalid address")); + mockUpdateOrder.mockRejectedValue(new Error("Invalid address")); const result = await updateOrderAddresses("order-1", {}); @@ -93,7 +94,7 @@ describe("checkout server actions", () => { }); it("returns fallback message for non-Error throws", async () => { - mockUpdateAddresses.mockRejectedValue("unexpected"); + mockUpdateOrder.mockRejectedValue("unexpected"); const result = await updateOrderAddresses("order-1", {}); @@ -104,6 +105,52 @@ describe("checkout server actions", () => { }); }); + describe("updateOrderMarket", () => { + it("returns success with updated order", async () => { + const updatedOrder = { ...mockOrder, currency: "EUR", locale: "de" }; + mockUpdateOrder.mockResolvedValue(updatedOrder); + + const result = await updateOrderMarket("order-1", { + currency: "EUR", + locale: "de", + }); + + expect(mockUpdateOrder).toHaveBeenCalledWith("order-1", { + currency: "EUR", + locale: "de", + }); + expect(result).toEqual({ success: true, order: updatedOrder }); + }); + + it("returns error on failure", async () => { + mockUpdateOrder.mockRejectedValue(new Error("Currency not supported")); + + const result = await updateOrderMarket("order-1", { + currency: "XYZ", + locale: "en", + }); + + expect(result).toEqual({ + success: false, + error: "Currency not supported", + }); + }); + + it("returns fallback message for non-Error throws", async () => { + mockUpdateOrder.mockRejectedValue("unexpected"); + + const result = await updateOrderMarket("order-1", { + currency: "EUR", + locale: "de", + }); + + expect(result).toEqual({ + success: false, + error: "Failed to update order market", + }); + }); + }); + describe("advanceCheckout", () => { it("returns success with order", async () => { mockAdvance.mockResolvedValue(mockOrder); diff --git a/src/lib/data/checkout.ts b/src/lib/data/checkout.ts index 6c7c7b6e..cf37d950 100644 --- a/src/lib/data/checkout.ts +++ b/src/lib/data/checkout.ts @@ -8,7 +8,7 @@ import { complete, getCheckout, removeCoupon, - updateAddresses, + updateOrder, } from "@spree/next"; import type { AddressParams } from "@spree/sdk"; import { cookies } from "next/headers"; @@ -40,11 +40,21 @@ export async function updateOrderAddresses( }, ) { return actionResult(async () => { - const order = await updateAddresses(orderId, addresses); + const order = await updateOrder(orderId, addresses); return { order }; }, "Failed to update addresses"); } +export async function updateOrderMarket( + orderId: string, + params: { currency: string; locale: string }, +) { + return actionResult(async () => { + const order = await updateOrder(orderId, params); + return { order }; + }, "Failed to update order market"); +} + export async function advanceCheckout(orderId: string) { return actionResult(async () => { const order = await advance(orderId); diff --git a/src/lib/data/markets.ts b/src/lib/data/markets.ts new file mode 100644 index 00000000..e904283d --- /dev/null +++ b/src/lib/data/markets.ts @@ -0,0 +1,19 @@ +"use server"; + +import { + listMarketCountries as _listMarketCountries, + listMarkets as _listMarkets, + resolveMarket as _resolveMarket, +} from "@spree/next"; + +export async function getMarkets() { + return _listMarkets(); +} + +export async function resolveMarket(country: string) { + return _resolveMarket(country); +} + +export async function getMarketCountries(marketId: string) { + return _listMarketCountries(marketId); +}