Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/mock-shop-store-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shopify/hydrogen": patch
---

Cart permalinks now hand off to the mock.shop demo store for every mock.shop host, not only `mock.shop` itself, so a storefront built against a per-store host such as `pets.mock.shop` behaves the same in mock mode. The `hydrogen-storefront-client` and `hydrogen-setup` skills now explain that mock.shop is a catalog of stores and how to pick one from https://mock.shop/llms.txt.
10 changes: 9 additions & 1 deletion examples/hydrogen/app/components/MockShopNotice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@ export function MockShopNotice() {
<div className="inner">
<h2 id="mock-shop-notice-heading">Welcome to Hydrogen!</h2>
<p>
You&rsquo;re seeing mocked products because no store is connected to this project yet.
You&rsquo;re seeing mock.shop products because no store is connected to this project yet.
</p>
<p>
mock.shop contains more than 100 sample stores. Browse the directory at{" "}
<a href="https://mock.shop/llms.txt" target="_blank" rel="noreferrer noopener">
mock.shop/llms.txt
</a>{" "}
and set <code>PUBLIC_STORE_DOMAIN</code> to a store&rsquo;s host, such as{" "}
<code>pets.mock.shop</code>, to build against its catalog.
</p>
<p>
Link a store by running <code>npx shopify hydrogen link</code> in your terminal.
Expand Down
5 changes: 4 additions & 1 deletion examples/hydrogen/app/routes/($locale)._index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,11 @@ async function loadCriticalData({ context }: Route.LoaderArgs) {
throw new Response("Featured collection not found", { status: 404 });
}

const storeDomain = context.env.PUBLIC_STORE_DOMAIN;

return {
isShopLinked: Boolean(context.env.PUBLIC_STORE_DOMAIN),
// A mock.shop store on its own host (e.g. pets.mock.shop) is still mock data.
isShopLinked: Boolean(storeDomain && !/(^|\.)mock\.shop$/.test(storeDomain)),
featuredCollection,
};
}
Expand Down
8 changes: 8 additions & 0 deletions examples/hydrogen/e2e/envs/.env.mockShopStore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
SESSION_SECRET="mock-session-secret-32-characters-ok"
PUBLIC_CHECKOUT_DOMAIN="pets.mock.shop"
PUBLIC_STORE_DOMAIN="pets.mock.shop"
PUBLIC_STOREFRONT_API_TOKEN=""
PRIVATE_STOREFRONT_API_TOKEN="mock-private-token"
PUBLIC_STOREFRONT_ID=""
PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID="shp_e2b55f4e-9dd6-48aa-91f8-5fa419fda119"
SHOP_ID="55145660472"
1 change: 1 addition & 0 deletions examples/hydrogen/e2e/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export const test = base.extend<

const TEST_STORE_KEYS = [
"mockShop",
"mockShopStore",
"defaultConsentDisallowed_cookiesEnabled",
"defaultConsentAllowed_cookiesEnabled",
"defaultConsentDisallowed_cookiesDisabled",
Expand Down
22 changes: 22 additions & 0 deletions examples/hydrogen/e2e/specs/smoke/mockShopStore.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { test, expect, setTestStore } from "../../fixtures";

setTestStore("mockShopStore");

// This smoke spec does not need the global loadtest header, and Monorail does
// not allow that custom header in browser CORS preflights.
test.use({ extraHTTPHeaders: {} });

test.describe("mock.shop store on its own host", () => {
test("renders that store's catalog and keeps the mock.shop notice", async ({ page }) => {
await page.goto("/");

const notice = page.getByRole("region", { name: "Welcome to Hydrogen!" });
await expect(notice).toBeVisible();
await expect(notice.getByRole("link", { name: "mock.shop/llms.txt" })).toBeVisible();

await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await expect(
page.getByRole("region", { name: "Recommended Products" }).getByRole("link").first(),
).toBeVisible();
});
});
2 changes: 2 additions & 0 deletions packages/hydrogen/skills/hydrogen-setup/steps/2-scaffold.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ If the framework requires a prefix to expose client-side variables, preserve the

List `PUBLIC_STOREFRONT_API_TOKEN` in the app's env example file as a commented-out entry (`# PUBLIC_STOREFRONT_API_TOKEN=`). The Storefront client accepts `undefined` as tokenless access, which is all mock.shop supports, so the scaffold works before the user has tokens and upgrades in place when they add one. Do not write an uncommented empty assignment (`PUBLIC_STOREFRONT_API_TOKEN=`): env loaders parse that as an empty string, and the client rejects empty tokens. Recommend filling it in (or switching to a private client) once the app targets a real store.

When the user has no store yet, point `PUBLIC_STORE_DOMAIN` at a mock.shop store. `mock.shop` is the default apparel catalog; https://mock.shop/llms.txt lists every other fictional store with its host (for example `pets.mock.shop`) and what it sells. Pick the store closest to what the user is building, and see the `hydrogen-storefront-client` skill for the details.

### Continue when

- [ ] Env vars follow the canonical names (plus any required framework prefix)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ Environment variables are still a **server-side input boundary**. Do not read `p

Default to a public client. `publicStorefrontToken` accepts `undefined`, which means tokenless access — there is no rate limit for tokenless clients, and it is all mock.shop supports. Always recommend a token-backed client (public or private) once the app targets a real store: token-based Storefront API access is required for product tags, metaobjects, metafields, menus, and customers. Public and private tokens can query the same token-required fields; the difference is whether the token is safe to expose and whether the request has trusted buyer context.

### mock.shop is a catalog of stores

mock.shop is not one store. The default store at `mock.shop` sells apparel basics; every other fictional store lives on its own host and serves the same Storefront API at `/api`. When the user has no store yet, read the directory at https://mock.shop/llms.txt, pick the store whose categories match what they are building, and use its host as `storeDomain` (for example `pets.mock.shop`). Each store describes its own catalog at `https://<store>.mock.shop/llms.txt`. Every mock.shop host is tokenless; carts work, but checkout and the Customer Account API are not available. Moving to a real store changes only `storeDomain` and the token.

### Public client (default)

Wire `publicStorefrontToken` from the canonical env variable without asserting it exists. When `PUBLIC_STOREFRONT_API_TOKEN` is unset the client runs tokenless, so the same scaffold works against mock.shop before the user has tokens and upgrades in place when they add one. In env example files list the variable commented out (`# PUBLIC_STOREFRONT_API_TOKEN=`) rather than as an empty assignment: env loaders parse `PUBLIC_STOREFRONT_API_TOKEN=` as an empty string, and the client rejects empty tokens.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,19 @@ describe("handleCheckoutRedirect", () => {
expect(mockFetch).not.toHaveBeenCalled();
});

it("redirects cart permalinks for a per-store mock.shop host to the demo store", async () => {
const result = await handleCheckoutRedirect(
new Request("https://my-app.com/cart/123:2?payment=shop_pay&source=hydrogen"),
{ storeDomain: "pets.mock.shop" },
);

expect(result?.status).toBe(302);
expect(result?.headers.get("location")).toBe(
"https://demostore.mock.shop/cart/123:2?payment=shop_pay&source=hydrogen",
);
expect(mockFetch).not.toHaveBeenCalled();
});

it("merges checkout URL search params into variant cart permalinks when a cart exists", async () => {
mockFetch.mockResolvedValueOnce(
mockGqlResponse({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,13 @@ async function getCartRedirectUrl(

function getCartPermalinkOrigin(storeUrl: string): string {
const url = new URL(storeUrl);
return url.hostname === "mock.shop" ? MOCK_SHOP_CART_PERMALINK_ORIGIN : url.origin;
return isMockShopHost(url.hostname) ? MOCK_SHOP_CART_PERMALINK_ORIGIN : url.origin;
}

// mock.shop serves many stores, each on its own host (pets.mock.shop, ...), and
// none of them renders cart permalinks, so every mock host hands off to the demo store.
function isMockShopHost(hostname: string): boolean {
return hostname === "mock.shop" || hostname.endsWith(".mock.shop");
}

function mergeSearchParams(target: URL, source: URLSearchParams): void {
Expand Down
5 changes: 5 additions & 0 deletions templates/nextjs/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ SESSION_SECRET=
SITE_ORIGIN=http://localhost:3000

# Browser-safe public storefront identity. Next.js inlines NEXT_PUBLIC_* values.
#
# Without a PRIVATE_STOREFRONT_API_TOKEN the app reads mock.shop, which is many
# stores on their own hosts (directory: https://mock.shop/llms.txt). Set
# NEXT_PUBLIC_STORE_DOMAIN to one of those hosts (for example pets.mock.shop) to
# build against that catalog instead of the default apparel store at mock.shop.
NEXT_PUBLIC_STORE_DOMAIN=
NEXT_PUBLIC_STOREFRONT_API_TOKEN=
NEXT_PUBLIC_SHOP_ID=
Expand Down
9 changes: 9 additions & 0 deletions templates/nextjs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,12 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
This storefront is scaffolded from Shopify's Hydrogen Next.js template. See the README for framework-specific details.

Use the [Shopify AI Toolkit](https://shopify.dev/docs/apps/build/ai-toolkit) for all Shopify API and platform work. If missing, install it in the agent host per that page (or `npx skills add Shopify/shopify-ai-toolkit --list` for skill-compatible hosts).

## No store yet?

Until a store is connected, this project reads [mock.shop](https://mock.shop): a public, auth-free Storefront API backed by fictional stores. There are more than 100 sample stores, each on its own host with its own catalog.

- The directory at https://mock.shop/llms.txt lists every store with what it sells and its API URL. The default, `mock.shop` itself, is apparel basics.
- To build against a different store, set `NEXT_PUBLIC_STORE_DOMAIN` in `.env` to that store's host (for example `pets.mock.shop`) and leave `PRIVATE_STOREFRONT_API_TOKEN` empty. Each store describes its own catalog at `https://<store>.mock.shop/llms.txt`.
- Carts work; checkout is mocked (no payment, no real order), and the Customer Account API isn't available.
- To connect a real store, set `PRIVATE_STOREFRONT_API_TOKEN` and `NEXT_PUBLIC_STORE_DOMAIN` in `.env` (see `.env.example`).
2 changes: 2 additions & 0 deletions templates/nextjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ Public values:

If `PRIVATE_STOREFRONT_API_TOKEN` is unset, the app uses `mock.shop`. If you set a private token, you must also set `NEXT_PUBLIC_STORE_DOMAIN`.

`mock.shop` is a catalog of fictional stores, each on its own host. The default at `mock.shop` sells apparel basics; the directory at [mock.shop/llms.txt](https://mock.shop/llms.txt) lists every other store with what it sells. Set `NEXT_PUBLIC_STORE_DOMAIN` to one of those hosts (for example `pets.mock.shop`) to build against that store's catalog while staying in mock mode.

## Scripts

| Script | Does |
Expand Down
25 changes: 19 additions & 6 deletions templates/nextjs/lib/storefront-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,21 @@ import { getOptionalPrivateStorefrontToken } from "./env";
* to the public mock.shop endpoint using its well-known `mock-private-token`.
* With a real private token present, `NEXT_PUBLIC_STORE_DOMAIN` must identify
* the store.
*
* mock.shop is a catalog of fictional stores, not one store. The default store at
* `mock.shop` sells apparel basics; every other store lives on its own host
* (pets.mock.shop, ...) and is listed at https://mock.shop/llms.txt. Setting
* `NEXT_PUBLIC_STORE_DOMAIN` to one of those hosts selects that catalog and keeps
* the app in mock mode.
*/

export const MOCK_SHOP_DOMAIN = "mock.shop";
export const MOCK_SHOP_PRIVATE_TOKEN = "mock-private-token";

export function isMockShopDomain(domain: string): boolean {
return domain === MOCK_SHOP_DOMAIN || domain.endsWith(`.${MOCK_SHOP_DOMAIN}`);
}

const SESSION_SECRET_MIN_LENGTH = 32;

export type ResolvedStorefrontConfig = {
Expand All @@ -37,7 +48,7 @@ let mockShopFallbackWarned = false;
export function isCustomerAccountsAvailable(): boolean {
const { storeDomain } = resolveStorefrontConfig();
return (
storeDomain !== MOCK_SHOP_DOMAIN &&
!isMockShopDomain(storeDomain) &&
Boolean(process.env.NEXT_PUBLIC_SHOP_ID) &&
Boolean(process.env.NEXT_PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID) &&
Boolean(process.env.SITE_ORIGIN) &&
Expand All @@ -49,23 +60,25 @@ export function isCustomerAccountsAvailable(): boolean {

export function resolveStorefrontConfig(): ResolvedStorefrontConfig {
const privateStorefrontToken = getOptionalPrivateStorefrontToken();
const configuredDomain = storefrontConfig.storeDomain;

if (!privateStorefrontToken) {
if (!privateStorefrontToken || isMockShopDomain(configuredDomain)) {
const storeDomain = isMockShopDomain(configuredDomain) ? configuredDomain : MOCK_SHOP_DOMAIN;
if (!mockShopFallbackWarned) {
mockShopFallbackWarned = true;
console.warn(
`[hydrogen-template-nextjs] No PRIVATE_STOREFRONT_API_TOKEN found — ` +
`running against mock.shop (${MOCK_SHOP_DOMAIN}). Set ` +
`[hydrogen-template-nextjs] Running against mock.shop (${storeDomain}). ` +
`Other mock stores are listed at https://mock.shop/llms.txt. Set ` +
`PRIVATE_STOREFRONT_API_TOKEN and NEXT_PUBLIC_STORE_DOMAIN to hit a real store.`,
);
}
return {
storeDomain: MOCK_SHOP_DOMAIN,
storeDomain,
privateStorefrontToken: MOCK_SHOP_PRIVATE_TOKEN,
};
}

const storeDomain = storefrontConfig.storeDomain;
const storeDomain = configuredDomain;
if (!storeDomain) {
throw new Error(
"NEXT_PUBLIC_STORE_DOMAIN is required when PRIVATE_STOREFRONT_API_TOKEN is set.",
Expand Down
5 changes: 5 additions & 0 deletions templates/react-router/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@

# Force the tokenless mock.shop demo (also the default when no token is set).
# MOCK_SHOP=1
#
# mock.shop is many stores, each on its own host. In mock mode, set
# PUBLIC_STORE_DOMAIN to one of the hosts listed at https://mock.shop/llms.txt
# (for example pets.mock.shop) to build against that store's catalog instead of
# the default apparel store at mock.shop.

# Real store. Set all three for real-store mode. On Oxygen, a linked storefront
# injects these for you.
Expand Down
9 changes: 9 additions & 0 deletions templates/react-router/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,12 @@
This storefront is scaffolded from Shopify's Hydrogen React Router template. See the README for framework-specific details.

Use the [Shopify AI Toolkit](https://shopify.dev/docs/apps/build/ai-toolkit) for all Shopify API and platform work. If missing, install it in the agent host per that page (or `npx skills add Shopify/shopify-ai-toolkit --list` for skill-compatible hosts).

## No store yet?

Until a store is connected, this project reads [mock.shop](https://mock.shop): a public, auth-free Storefront API backed by fictional stores. There are more than 100 sample stores, each on its own host with its own catalog.

- The directory at https://mock.shop/llms.txt lists every store with what it sells and its API URL. The default, `mock.shop` itself, is apparel basics.
- To build against a different store, set `PUBLIC_STORE_DOMAIN` in `.env` to that store's host (for example `pets.mock.shop`) and leave `PRIVATE_STOREFRONT_API_TOKEN` empty. Each store describes its own catalog at `https://<store>.mock.shop/llms.txt`.
- Carts work; checkout is mocked (no payment, no real order), and the Customer Account API isn't available.
- To connect a real store, set `PUBLIC_STORE_DOMAIN`, `PUBLIC_STOREFRONT_ID`, and `PRIVATE_STOREFRONT_API_TOKEN` in `.env` (see `.env.example`). On Oxygen, a linked storefront injects them for you.
9 changes: 8 additions & 1 deletion templates/react-router/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ cp .env.example .env
npm run dev
```

`mock.shop` is a catalog of fictional stores, each on its own host. The default at
`mock.shop` sells apparel basics; the directory at
[mock.shop/llms.txt](https://mock.shop/llms.txt) lists every other store with what it
sells. To build against one of them, set `PUBLIC_STORE_DOMAIN` to its host (for
example `pets.mock.shop`) and leave the token empty.

**Against a real store** — set your store domain, storefront ID, and a **private**
Storefront API token, then run normally:

Expand All @@ -63,7 +69,8 @@ app talks to the real store (`PUBLIC_STORE_DOMAIN`, falling back to the default
`app/lib/shop.ts`); with none it falls back to the `mock.shop` demo, so a fresh
deploy always renders. **On Oxygen, a linked storefront injects these env vars
automatically** — the deployed site connects to your store with no extra config
(and shows the `mock.shop` demo until it's linked). `MOCK_SHOP=1` forces mock.
(and shows the `mock.shop` demo until it's linked). `MOCK_SHOP=1` forces mock, and so
does a `mock.shop` host in `PUBLIC_STORE_DOMAIN`.
(`mock.shop` and the Hydrogen Preview store are different data sources.)

## Scripts
Expand Down
26 changes: 25 additions & 1 deletion templates/react-router/__test__/shop.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";

import { shouldUseMockShop } from "../app/lib/shop.ts";
import { getMockShopDomain, isMockShopDomain, shouldUseMockShop } from "../app/lib/shop.ts";

test("uses a real store only when credentials exist and mock mode is not forced", () => {
assert.equal(shouldUseMockShop({}), true);
Expand All @@ -10,4 +10,28 @@ test("uses a real store only when credentials exist and mock mode is not forced"
shouldUseMockShop({ MOCK_SHOP: "1", PRIVATE_STOREFRONT_API_TOKEN: "private-token" }),
true,
);
assert.equal(
shouldUseMockShop({
PRIVATE_STOREFRONT_API_TOKEN: "private-token",
PUBLIC_STORE_DOMAIN: "pets.mock.shop",
}),
true,
);
});

test("recognizes the default mock.shop store and per-store mock.shop hosts", () => {
assert.equal(isMockShopDomain("mock.shop"), true);
assert.equal(isMockShopDomain("pets.mock.shop"), true);
assert.equal(isMockShopDomain("hydrogen-preview.myshopify.com"), false);
assert.equal(isMockShopDomain("notmock.shop"), false);
assert.equal(isMockShopDomain(undefined), false);
});

test("mock mode reads the store from PUBLIC_STORE_DOMAIN when it is a mock.shop host", () => {
assert.equal(getMockShopDomain({}), "mock.shop");
assert.equal(getMockShopDomain({ PUBLIC_STORE_DOMAIN: "pets.mock.shop" }), "pets.mock.shop");
assert.equal(
getMockShopDomain({ PUBLIC_STORE_DOMAIN: "hydrogen-preview.myshopify.com" }),
"mock.shop",
);
});
34 changes: 28 additions & 6 deletions templates/react-router/app/lib/shop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
// present. On Oxygen, a linked storefront injects PRIVATE_STOREFRONT_API_TOKEN
// and PUBLIC_STORE_DOMAIN; for local real-store dev set them in `.env`.
// • mock.shop — the tokenless fallback used when no token is present (so a
// fresh deploy always renders), and forced explicitly by MOCK_SHOP=1.
// fresh deploy always renders), forced explicitly by MOCK_SHOP=1, and implied
// by a mock.shop host in PUBLIC_STORE_DOMAIN. mock.shop is many stores, each
// on its own host (directory: https://mock.shop/llms.txt); PUBLIC_STORE_DOMAIN
// picks one (e.g. pets.mock.shop), otherwise the default store at mock.shop.
//
// `storeDomain` below is the default used only when PUBLIC_STORE_DOMAIN is unset.
// It points at Shopify's public Hydrogen Preview store as an EXAMPLE — replace it
Expand All @@ -18,13 +21,32 @@ export const storefrontConfig = {
i18n: { country: "US", language: "EN" },
} as const;

// Real store iff a private Storefront API token is available; otherwise the
// tokenless mock.shop demo. MOCK_SHOP=1 forces mock (used by the gate + as the
// zero-config default).
// Real store iff a private Storefront API token is available and the store isn't
// a mock.shop host; otherwise the tokenless mock.shop demo. MOCK_SHOP=1 forces
// mock (used by the gate + as the zero-config default).
export function shouldUseMockShop(
env: Pick<Env, "MOCK_SHOP" | "PRIVATE_STOREFRONT_API_TOKEN">,
env: Pick<Env, "MOCK_SHOP" | "PRIVATE_STOREFRONT_API_TOKEN" | "PUBLIC_STORE_DOMAIN">,
): boolean {
return env.MOCK_SHOP === "1" || !env.PRIVATE_STOREFRONT_API_TOKEN;
return (
env.MOCK_SHOP === "1" ||
!env.PRIVATE_STOREFRONT_API_TOKEN ||
isMockShopDomain(env.PUBLIC_STORE_DOMAIN)
);
}

// mock.shop is a catalog of stores, not one store. The default store at mock.shop
// sells apparel basics; every other store lives on its own host (pets.mock.shop,
// snowboards.mock.shop, ...) and is listed at https://mock.shop/llms.txt.
export const MOCK_SHOP_DOMAIN = "mock.shop";

export function isMockShopDomain(domain: string | undefined): domain is string {
return domain === MOCK_SHOP_DOMAIN || Boolean(domain?.endsWith(`.${MOCK_SHOP_DOMAIN}`));
}

// Store domain for mock mode: a mock.shop host in PUBLIC_STORE_DOMAIN selects that
// store's catalog; anything else means the default store.
export function getMockShopDomain(env: Pick<Env, "PUBLIC_STORE_DOMAIN">): string {
return isMockShopDomain(env.PUBLIC_STORE_DOMAIN) ? env.PUBLIC_STORE_DOMAIN : MOCK_SHOP_DOMAIN;
}

// Store domain for real-store mode: prefer the worker env (Oxygen injects
Expand Down
Loading
Loading