Skip to content
Draft
Show file tree
Hide file tree
Changes from 11 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: 3 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@
"packages/hydrogen/src/core/shopify-scripts/utils/tracking-values.ts",
"packages/hydrogen/src/core/cart/actions.ts",
"packages/hydrogen/src/core/cart/queries.ts",
"packages/hydrogen/src/core/localization/get-localization.ts",
"packages/hydrogen/src/core/localization/queries.ts",
"packages/hydrogen/src/core/localization/server-handlers.ts",
"packages/hydrogen/src/core/predictive-search/queries.ts",
"packages/hydrogen/src/core/predictive-search/search.ts",
"packages/hydrogen/src/core/collection/reconciler.ts",
Expand Down
109 changes: 109 additions & 0 deletions examples/hydrogen/app/components/CountrySelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {
LOCALIZATION_API_PATH,
LOCALIZATION_COUNTRY_FIELD,
LOCALIZATION_LANGUAGE_FIELD,
LOCALIZATION_REDIRECT_TO_FIELD,
} from "@shopify/hydrogen";
import type { LocalizationData } from "@shopify/hydrogen";
import { useEffect, useState } from "react";
import { useLocation } from "react-router";

import type { I18nLocale } from "~/lib/i18n";

interface CountrySelectorProps {
localization: LocalizationData;
i18n: Pick<I18nLocale, "country" | "language">;
}

/**
* Progressively enhanced country/currency and language selector.
*
* Layer 0 (no JavaScript): plain HTML forms with visible submit buttons post to the
* localization endpoint, which validates the selection, updates the cart buyer identity,
* saves it to the session, and 303-redirects back to this page under the new locale's prefix.
*
* Layer 1 (hydrated): selecting an option submits immediately and the buttons hide. The
* submit is still a full document navigation — every rendered price changes with the locale.
*
* Country and language are separate forms (the Liquid theme pattern) so a country switch
* never submits a language the new country doesn't offer — the server picks the best
* language for the new country instead.
*/
export function CountrySelector({ localization, i18n }: CountrySelectorProps) {
const { pathname, search } = useLocation();
const redirectTo = pathname + search;
const enhanced = useEnhanced();

const currentCountry = localization.availableCountries.find(
(country) => country.isoCode === i18n.country,
);
const availableLanguages = currentCountry?.availableLanguages ?? [];

return (
<div className="country-selector">
<form method="post" action={LOCALIZATION_API_PATH}>
<input type="hidden" name={LOCALIZATION_REDIRECT_TO_FIELD} value={redirectTo} />
<label>
<span className="sr-only">Country</span>
<select
name={LOCALIZATION_COUNTRY_FIELD}
defaultValue={i18n.country}
onChange={submitFormOnChange}
>
{localization.availableCountries.map((country) => (
<option key={country.isoCode} value={country.isoCode}>
{country.name} ({country.currency.isoCode} {country.currency.symbol})
</option>
))}
</select>
</label>
<button type="submit" hidden={enhanced}>
Update country
</button>
</form>

{availableLanguages.length > 1 && (
<form method="post" action={LOCALIZATION_API_PATH}>
<input type="hidden" name={LOCALIZATION_REDIRECT_TO_FIELD} value={redirectTo} />
<input type="hidden" name={LOCALIZATION_COUNTRY_FIELD} value={i18n.country} />
<label>
<span className="sr-only">Language</span>
<select
name={LOCALIZATION_LANGUAGE_FIELD}
defaultValue={i18n.language}
onChange={submitFormOnChange}
>
{availableLanguages.map((language) => (
<option
key={language.isoCode}
value={language.isoCode}
lang={language.isoCode.toLowerCase()}
>
{language.endonymName}
</option>
))}
</select>
</label>
<button type="submit" hidden={enhanced}>
Update language
</button>
</form>
)}
</div>
);
}

/**
* The upcoming localization client store debounces rapid keyboard selection before
* navigating; this lightweight example submits directly on commit of a selection.
*/
function submitFormOnChange(event: React.ChangeEvent<HTMLSelectElement>) {
event.currentTarget.form?.requestSubmit();
}

/** False during SSR and before hydration, so the no-JS submit buttons stay visible. */
function useEnhanced(): boolean {
const [enhanced, setEnhanced] = useState(false);
useEffect(() => setEnhanced(true), []);
return enhanced;
}
27 changes: 22 additions & 5 deletions examples/hydrogen/app/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
import { AnalyticsEvent, type CartData, type CartPending } from "@shopify/hydrogen";
import {
AnalyticsEvent,
type CartData,
type CartPending,
type LocalizationData,
} from "@shopify/hydrogen";
import { Suspense } from "react";
import { Await, NavLink } from "react-router";

import { useAside } from "~/components/Aside";
import { CountrySelector } from "~/components/CountrySelector";
import { toAnalyticsCart, useAnalytics } from "~/lib/analytics";
import { useCart } from "~/lib/cart";
import type { HeaderQuery } from "~/lib/fragments";
import type { I18nLocale } from "~/lib/i18n";

interface HeaderProps {
header: HeaderQuery;
i18n: I18nLocale;
isLoggedIn: Promise<boolean>;
pathPrefix: string;
localization: LocalizationData;
publicStoreDomain: string;
}

type Viewport = "desktop" | "mobile";

export function Header({ header, isLoggedIn, pathPrefix, publicStoreDomain }: HeaderProps) {
export function Header({ header, i18n, isLoggedIn, localization, publicStoreDomain }: HeaderProps) {
const { shop, menu } = header;
return (
<header className="header">
Expand All @@ -29,7 +37,11 @@ export function Header({ header, isLoggedIn, pathPrefix, publicStoreDomain }: He
primaryDomainUrl={header.shop.primaryDomain.url}
publicStoreDomain={publicStoreDomain}
/>
<HeaderCtas isLoggedIn={isLoggedIn} pathPrefix={pathPrefix} />
<HeaderCtas
isLoggedIn={isLoggedIn}
pathPrefix={i18n.pathPrefix}
countrySelector={<CountrySelector localization={localization} i18n={i18n} />}
/>
</header>
);
}
Expand Down Expand Up @@ -83,13 +95,18 @@ export function HeaderMenu({
);
}

function HeaderCtas({ isLoggedIn, pathPrefix }: Pick<HeaderProps, "isLoggedIn" | "pathPrefix">) {
function HeaderCtas({
isLoggedIn,
pathPrefix,
countrySelector,
}: Pick<HeaderProps, "isLoggedIn"> & { pathPrefix: string; countrySelector: React.ReactNode }) {
const accountPath = `${pathPrefix}/account`;
const loginPath = `/account/login?return_to=${encodeURIComponent(accountPath)}`;

return (
<nav className="header-ctas" role="navigation">
<HeaderMenuMobileToggle />
{countrySelector}
<Suspense fallback={<a href={loginPath}>Sign in</a>}>
<Await resolve={isLoggedIn} errorElement={<a href={loginPath}>Sign in</a>}>
{(isLoggedIn) =>
Expand Down
9 changes: 7 additions & 2 deletions examples/hydrogen/app/components/PageLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { LocalizationData } from "@shopify/hydrogen";
import { PredictiveSearchProvider } from "@shopify/hydrogen/react";
import { Await, Link } from "react-router";

Expand All @@ -8,14 +9,16 @@ import { Header, HeaderMenu } from "~/components/Header";
import { getSearchPageUrl, SearchFormPredictive } from "~/components/SearchFormPredictive";
import { SearchResultsPredictive } from "~/components/SearchResultsPredictive";
import type { FooterQuery, HeaderQuery } from "~/lib/fragments";
import type { I18nLocale } from "~/lib/i18n";

const PREDICTIVE_SEARCH_LIMIT = 5;

interface PageLayoutProps {
footer: Promise<FooterQuery | null>;
header: HeaderQuery;
i18n: { pathPrefix: string };
i18n: I18nLocale;
isLoggedIn: Promise<boolean>;
localization: LocalizationData;
publicStoreDomain: string;
children?: React.ReactNode;
}
Expand All @@ -26,6 +29,7 @@ export function PageLayout({
header,
i18n,
isLoggedIn,
localization,
publicStoreDomain,
}: PageLayoutProps) {
return (
Expand All @@ -36,8 +40,9 @@ export function PageLayout({
{header && (
<Header
header={header}
i18n={i18n}
isLoggedIn={isLoggedIn}
pathPrefix={i18n.pathPrefix}
localization={localization}
publicStoreDomain={publicStoreDomain}
/>
)}
Expand Down
52 changes: 34 additions & 18 deletions examples/hydrogen/app/lib/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,41 @@
import type { I18nConfig } from "@shopify/hydrogen";
import { matchLocaleFromRequest } from "@shopify/hydrogen";
import type { MatchedLocale, SupportedLocale } from "@shopify/hydrogen";

export type I18nLocale = I18nConfig & {
pathPrefix: string;
};
export type I18nLocale = MatchedLocale;

const LOCALES_BY_PATH_PART: Record<string, Pick<I18nLocale, "country" | "language">> = {
"EN-CA": { country: "CA", language: "EN" },
"EN-US": { country: "US", language: "EN" },
"FR-CA": { country: "CA", language: "FR" },
};
/** Locale served at unprefixed paths. Changing it re-homes the whole URL space — deploy-worthy. */
export const DEFAULT_LOCALE: SupportedLocale = { country: "US", language: "EN" };

/**
* The locales this storefront serves under `/{language}-{country}` prefixes (strict mode).
* Shared with the localization server handlers so the selector can never offer a locale the
* router won't serve. Omit `supportedLocales` everywhere for permissive mode instead (any
* valid ISO pair matches, driven live by Shopify Markets).
*/
export const SUPPORTED_LOCALES: readonly SupportedLocale[] = [
DEFAULT_LOCALE,
{ country: "CA", language: "EN" },
{ country: "CA", language: "FR" },
];

const REACT_ROUTER_DATA_SUFFIX_RE = /\.data$/;

export function getLocaleFromRequest(request: Request): I18nLocale {
const url = new URL(request.url);
const firstPathPart = url.pathname.split("/")[1]?.toUpperCase() ?? "";
let pathPrefix = "";
let locale = LOCALES_BY_PATH_PART["EN-US"];
return matchLocaleFromRequest(normalizeDataRequest(request), {
defaultLocale: DEFAULT_LOCALE,
supportedLocales: SUPPORTED_LOCALES,
});
}

if (LOCALES_BY_PATH_PART[firstPathPart]) {
pathPrefix = "/" + firstPathPart;
locale = LOCALES_BY_PATH_PART[firstPathPart];
}
/**
* React Router single-fetch requests append `.data` to the pathname (`/fr-ca.data`), which
* would keep the locale prefix from matching. Framework URL quirks are normalized here, at
* the integration boundary — the package matcher stays framework-agnostic.
*/
function normalizeDataRequest(request: Request): Request {
const url = new URL(request.url);
if (!REACT_ROUTER_DATA_SUFFIX_RE.test(url.pathname)) return request;

return { ...locale, pathPrefix };
url.pathname = url.pathname.replace(REACT_ROUTER_DATA_SUFFIX_RE, "");
return new Request(url, { headers: request.headers });
}
24 changes: 21 additions & 3 deletions examples/hydrogen/app/root.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { Cache, type ConsentConfig } from "@shopify/hydrogen";
import {
Cache,
getSupportedCountries,
localizationQueries,
type ConsentConfig,
} from "@shopify/hydrogen";
import { ShopifyScripts } from "@shopify/hydrogen/react";
import {
Outlet,
Expand All @@ -19,6 +24,7 @@ import { CartProvider } from "~/lib/cart";
import { cartHandlers } from "~/lib/cart-handlers";
import { useNonce } from "~/lib/csp";
import { FOOTER_QUERY, HEADER_QUERY, type HeaderQuery } from "~/lib/fragments";
import { SUPPORTED_LOCALES } from "~/lib/i18n";
import { routeTemplates } from "~/lib/route-templates";

import type { Route } from "./+types/root";
Expand Down Expand Up @@ -104,17 +110,29 @@ async function loadCriticalData(args: Route.LoaderArgs) {
const { context } = args;
const { storefront } = context;

const [header, cartData] = await Promise.all([
const [header, cartData, localizationData] = await Promise.all([
storefront.query(HEADER_QUERY, {
cache: Cache.long(),
variables: {
headerMenuHandle: "main-menu", // Adjust to your header menu handle
},
}),
cartHandlers.get({ storefrontClient: storefront }).then(({ data }) => data),
// Country/language lists for the selector; cached because Markets config rarely changes.
storefront.query(localizationQueries.localization, { cache: Cache.long() }),
]);

return { cartData, header };
const localization = {
...localizationData.localization,
// Same intersection the localization endpoints apply: the selector must never offer a
// locale the router won't serve.
availableCountries: getSupportedCountries(
localizationData.localization.availableCountries,
SUPPORTED_LOCALES,
),
};

return { cartData, header, localization };
}

function getAnalyticsCurrency(header: HeaderQuery): string | null {
Expand Down
31 changes: 31 additions & 0 deletions examples/hydrogen/app/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,37 @@ button.reset:hover:not(:has(> *)) {
min-width: fit-content;
}

.country-selector {
align-items: stretch;
border: 1px solid var(--color-dark);
border-radius: 4px;
display: flex;
overflow: hidden;
}

.country-selector form {
align-items: center;
display: flex;
}

.country-selector form + form {
border-left: 1px solid var(--color-dark);
}

.country-selector select {
background: transparent;
border: none;
max-width: 10rem;
padding: 0.25rem 0.5rem;
}

.country-selector button {
border: none;
border-left: 1px solid var(--color-dark);
background: transparent;
padding: 0.25rem 0.5rem;
}

/*
* --------------------------------------------------
* components/Footer
Expand Down
Loading
Loading