Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions oxfmt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export default defineConfig({
sortTailwindcss: true,
sortPackageJson: true,
ignorePatterns: [
// Standalone, generated template artifacts (own lockfile/.npmrc, pinned
// @shopify/hydrogen) that are copy-pasted into new repos by the build
// pipeline. Not workspace members; not hand-formatted here.
"templates/**",
"dist/**",
"build/**",
".next/**",
Expand Down
4 changes: 4 additions & 0 deletions templates/react-router/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
build/
.react-router/
.env
1 change: 1 addition & 0 deletions templates/react-router/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
registry=https://registry.npmjs.org
9 changes: 9 additions & 0 deletions templates/react-router/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
MIT License

Copyright (c) 2023-present, Shopify Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
73 changes: 73 additions & 0 deletions templates/react-router/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# React Router storefront example

A React Router 8 (framework mode, SSR) storefront built on
[`@shopify/hydrogen`](https://www.npmjs.com/package/@shopify/hydrogen). It's a
starting point you can clone and build your store on top of — five pages on a
shared layout, with a real cart, analytics, and a consent banner wired up.

## Pages

- `/` — home (editorial hero, best sellers, shop by category)
- `/products/:handle` — product detail (gallery, variants, add to cart)
- `/collections` — all collections
- `/collections/:handle` — collection with filters, sort, and pagination
- `/search` — product search with the same filtering
- `/cart` — cart (also the no-JS fallback for the cart drawer)

## What it demonstrates

- Server `loader`s as the data path; each route owns its GraphQL query (typed via
`gql.tada`).
- A real cart: storefront client + request handlers + `/api/cart` + an accessible
cart drawer wired to Shopify Standard Actions.
- A shared layout (header with mobile nav, footer, announcement bar).
- Analytics + a consent banner.
- The design tokens in `app/tokens.css` and SVG icons in `public/icons/`.

## Run it

```bash
pnpm install
```

**Zero-config demo** — runs against `mock.shop` (a public mock Storefront API, no
account or token needed):

```bash
MOCK_SHOP=1 pnpm dev
```

**Against a real store** — set your store domain (in `app/lib/shop.ts`) and a
**private** Storefront API token, then run normally:

```bash
cp .env.example .env # add your PRIVATE_STOREFRONT_API_TOKEN
pnpm dev # the dev/start scripts auto-load .env (--env-file-if-exists)
```

The store coordinates in `app/lib/shop.ts` point at Shopify's public **Hydrogen
Preview** store as a placeholder — **replace them with your own store**. Real
(non-mock) mode requires a private Storefront API token for *your* store; the
zero-config `MOCK_SHOP=1` demo above needs none. (`mock.shop` and the Hydrogen
Preview store are different data sources.)

## Scripts

| Script | Does |
| --- | --- |
| `pnpm dev` | Start the dev server (Vite + SSR). |
| `pnpm build` | Production build. |
| `pnpm start` | Serve the production build. |
| `pnpm typecheck` | React Router typegen + `tsc` + `gql.tada check`. |

## Where to start

- Swap the store in `app/lib/shop.ts` + `.env`.
- Routes live in `app/routes/`; shared UI in `app/components/`; data/query helpers
in `app/lib/`.
- The design is yours to change — `app/tokens.css` holds the design tokens; the
components use them via semantic classes.

## License

MIT — see [LICENSE](./LICENSE).
87 changes: 87 additions & 0 deletions templates/react-router/app/app.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
@import "tailwindcss";
@import "./tokens.css";

/* App-only CSS the frozen core (tokens.css) does not own.
*
* The core owns the design tokens AND the drawer *box* (.drawer-right /
* .drawer-left: position, size, surface, backdrop color, responsive width).
* It does NOT own the open/close MOTION or the body scroll lock — those are
* app-owned and live here.
*
* Do NOT add web fonts or @theme font overrides here: the core tokens lock the
* example typography to system-ui.
*/

/* Drawer slide-in animation.
* The cart drawer uses `.drawer-right` (slides from the inline-end edge); the
* mobile-nav drawer uses `.drawer-left` (slides from the inline-start edge).
* `overlay`/`display` with `allow-discrete` keep the dialog transitionable
* while it leaves the top layer on close. */
.drawer-right,
.drawer-left {
transition:
transform 250ms cubic-bezier(0.22, 1, 0.36, 1),
overlay 250ms allow-discrete,
display 250ms allow-discrete;
}

.drawer-right {
transform: translateX(100%);
}

.drawer-left {
transform: translateX(-100%);
}

.drawer-right[open],
.drawer-left[open] {
transform: translateX(0);
}

@starting-style {
.drawer-right[open] {
transform: translateX(100%);
}

.drawer-left[open] {
transform: translateX(-100%);
}
}

/* Backdrop fade-in. The core sets the open backdrop color; here we make it
* start transparent so it can transition in (and back out on close). */
.drawer-right::backdrop,
.drawer-left::backdrop {
background: rgb(0 0 0 / 0);
transition:
background-color 250ms ease-out,
overlay 250ms allow-discrete,
display 250ms allow-discrete;
}

.drawer-right[open]::backdrop,
.drawer-left[open]::backdrop {
background: rgb(0 0 0 / 0.5);
}

@starting-style {
.drawer-right[open]::backdrop,
.drawer-left[open]::backdrop {
background: rgb(0 0 0 / 0);
}
}

@media (prefers-reduced-motion: reduce) {
.drawer-right,
.drawer-left,
.drawer-right::backdrop,
.drawer-left::backdrop {
transition: none;
}
}

/* Body scroll lock while any drawer is open — pure CSS, no JS class toggling. */
body:has(.drawer-right[open]),
body:has(.drawer-left[open]) {
overflow: hidden;
}
150 changes: 150 additions & 0 deletions templates/react-router/app/components/AnalyticsTrackers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import type { AnalyticsCart, ConsentConfig, ShopAnalytics } from "@shopify/hydrogen";
import { useEffect, useRef } from "react";
import { useLocation } from "react-router";

import {
AnalyticsEvent,
configureAnalytics,
getAnalytics,
getAnalyticsShop,
} from "~/lib/analytics";
import { useCart } from "~/lib/cart";

type AnalyticsTapWindow = Window & {
__analyticsEvents?: Array<{ event: string; payload: Record<string, unknown> }>;
};

const TAP_EVENTS = [
AnalyticsEvent.PAGE_VIEWED,
AnalyticsEvent.PRODUCT_VIEWED,
AnalyticsEvent.COLLECTION_VIEWED,
AnalyticsEvent.CART_VIEWED,
AnalyticsEvent.SEARCH_VIEWED,
AnalyticsEvent.CART_UPDATED,
AnalyticsEvent.PRODUCT_ADD_TO_CART,
AnalyticsEvent.PRODUCT_REMOVED_FROM_CART,
] as const;

export function AnalyticsTracker({
shop,
consent,
enableTestTap,
}: {
shop: ShopAnalytics;
consent: ConsentConfig;
enableTestTap: boolean;
}) {
const location = useLocation();
const pageKey = `${location.pathname}${location.search}`;
const tapConfigured = useRef(false);

useEffect(() => {
configureAnalytics(shop, consent);
const analytics = getAnalytics();
if (!analytics) return;

if (enableTestTap && !tapConfigured.current) {
tapConfigured.current = true;
const win = window as AnalyticsTapWindow;
win.__analyticsEvents ??= [];
for (const event of TAP_EVENTS) {
analytics.subscribe(event, (payload) => {
win.__analyticsEvents?.push({ event, payload: payload as Record<string, unknown> });
});
}
}

analytics.publish(AnalyticsEvent.PAGE_VIEWED, {
url: window.location.href,
shop,
});
}, [pageKey, shop, consent, enableTestTap]);

return null;
}

function toAnalyticsCart(cart: unknown): AnalyticsCart | null {
const candidate = cart as {
id?: string | null;
updatedAt?: string;
lines?: {
nodes?: Array<{
id: string;
quantity: number;
cost?: { amountPerQuantity?: { amount: string; currencyCode?: string } };
merchandise?: {
id?: string;
title?: string;
sku?: string | null;
product?: {
id?: string;
title?: string;
vendor?: string;
productType?: string;
handle?: string;
};
};
}>;
};
};

if (!candidate.id || !candidate.updatedAt) return null;

return {
id: candidate.id,
updatedAt: candidate.updatedAt,
lines: {
nodes: (candidate.lines?.nodes ?? []).flatMap((line) => {
const merchandise = line.merchandise;
const product = merchandise?.product;
const price = line.cost?.amountPerQuantity;
if (!merchandise?.id || !product?.id || !product.title || !product.vendor || !price) {
return [];
}
return [
{
id: line.id,
quantity: line.quantity,
merchandise: {
id: merchandise.id,
title: merchandise.title ?? product.title,
sku: merchandise.sku,
price,
product: {
id: product.id,
title: product.title,
vendor: product.vendor,
productType: product.productType,
handle: product.handle,
},
},
},
];
}),
},
};
}

export function CartAnalyticsTracker() {
const cart = useCart((state) => state.data);
const analyticsKey = `${cart.id ?? ""}:${String(cart.updatedAt ?? "")}`;

useEffect(() => {
const analyticsCart = toAnalyticsCart(cart);
if (!analyticsCart) return;
getAnalytics()?.updateCart(analyticsCart);
}, [analyticsKey, cart]);

return null;
}

export function publishCartViewed(cart: unknown) {
const analytics = getAnalytics();
if (!analytics) return;
analytics.publish(AnalyticsEvent.CART_VIEWED, {
cart: toAnalyticsCart(cart),
prevCart: null,
url: window.location.href,
shop: getAnalyticsShop(),
});
}
Loading
Loading