@tanstack/create 0.65.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/frameworks/react/add-ons/shopify/README.md +86 -0
  3. package/dist/frameworks/react/add-ons/shopify/assets/_dot_env.local.append +19 -0
  4. package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/account-nav.tsx.ejs +41 -0
  5. package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/add-to-cart-button.tsx +48 -0
  6. package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/cart-line-item.tsx +94 -0
  7. package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/cart-summary.tsx +111 -0
  8. package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/empty-state.tsx +29 -0
  9. package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/money.tsx +11 -0
  10. package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/product-card.tsx +74 -0
  11. package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/product-grid.tsx +24 -0
  12. package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/shop-image.tsx +57 -0
  13. package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/shop.css +58 -0
  14. package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/variant-selector.tsx +79 -0
  15. package/dist/frameworks/react/add-ons/shopify/assets/src/hooks/use-cart.ts +276 -0
  16. package/dist/frameworks/react/add-ons/shopify/assets/src/hooks/use-customer.ts.ejs +22 -0
  17. package/dist/frameworks/react/add-ons/shopify/assets/src/integrations/shopify/header-cart.tsx +37 -0
  18. package/dist/frameworks/react/add-ons/shopify/assets/src/lib/shopify/customer-queries.ts.ejs +228 -0
  19. package/dist/frameworks/react/add-ons/shopify/assets/src/lib/shopify/format.ts +33 -0
  20. package/dist/frameworks/react/add-ons/shopify/assets/src/lib/shopify/queries.ts +684 -0
  21. package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.addresses.tsx.ejs +67 -0
  22. package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.callback.tsx.ejs +45 -0
  23. package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.index.tsx.ejs +70 -0
  24. package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.login.tsx.ejs +59 -0
  25. package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.logout.tsx.ejs +16 -0
  26. package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.orders.$id.tsx.ejs +126 -0
  27. package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.orders.tsx.ejs +50 -0
  28. package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.tsx.ejs +34 -0
  29. package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.cart.tsx +45 -0
  30. package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.collections.$handle.tsx +66 -0
  31. package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.index.tsx +36 -0
  32. package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.pages.$handle.tsx +39 -0
  33. package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.policies.$handle.tsx +30 -0
  34. package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.products.$handle.tsx +106 -0
  35. package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.search.tsx +75 -0
  36. package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.tsx +78 -0
  37. package/dist/frameworks/react/add-ons/shopify/assets/src/server/shopify/cart.functions.ts +207 -0
  38. package/dist/frameworks/react/add-ons/shopify/assets/src/server/shopify/catalog.functions.ts +244 -0
  39. package/dist/frameworks/react/add-ons/shopify/assets/src/server/shopify/cookies.ts +29 -0
  40. package/dist/frameworks/react/add-ons/shopify/assets/src/server/shopify/customer-client.ts.ejs +99 -0
  41. package/dist/frameworks/react/add-ons/shopify/assets/src/server/shopify/customer-cookies.ts.ejs +49 -0
  42. package/dist/frameworks/react/add-ons/shopify/assets/src/server/shopify/customer.functions.ts.ejs +168 -0
  43. package/dist/frameworks/react/add-ons/shopify/assets/src/server/shopify/env.ts +89 -0
  44. package/dist/frameworks/react/add-ons/shopify/assets/src/server/shopify/oauth.ts.ejs +301 -0
  45. package/dist/frameworks/react/add-ons/shopify/assets/src/server/shopify/storefront-client.ts +101 -0
  46. package/dist/frameworks/react/add-ons/shopify/info.json +104 -0
  47. package/dist/frameworks/react/add-ons/shopify/package.json +6 -0
  48. package/dist/frameworks/react/add-ons/shopify/small-logo.svg +1 -0
  49. package/dist/frameworks/react/examples/shopify-storefront/README.md +39 -0
  50. package/dist/frameworks/react/examples/shopify-storefront/assets/src/components/FeaturedCollections.tsx +43 -0
  51. package/dist/frameworks/react/examples/shopify-storefront/assets/src/components/ShopHero.tsx +39 -0
  52. package/dist/frameworks/react/examples/shopify-storefront/assets/src/routes/index.tsx +65 -0
  53. package/dist/frameworks/react/examples/shopify-storefront/info.json +18 -0
  54. package/dist/frameworks/react/examples/shopify-storefront/package.json +3 -0
  55. package/dist/frameworks/react/project/base/src/components/Header.tsx.ejs +34 -34
  56. package/package.json +1 -1
  57. package/src/frameworks/react/add-ons/shopify/README.md +86 -0
  58. package/src/frameworks/react/add-ons/shopify/assets/_dot_env.local.append +19 -0
  59. package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/account-nav.tsx.ejs +41 -0
  60. package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/add-to-cart-button.tsx +48 -0
  61. package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/cart-line-item.tsx +94 -0
  62. package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/cart-summary.tsx +111 -0
  63. package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/empty-state.tsx +29 -0
  64. package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/money.tsx +11 -0
  65. package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/product-card.tsx +74 -0
  66. package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/product-grid.tsx +24 -0
  67. package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/shop-image.tsx +57 -0
  68. package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/shop.css +58 -0
  69. package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/variant-selector.tsx +79 -0
  70. package/src/frameworks/react/add-ons/shopify/assets/src/hooks/use-cart.ts +276 -0
  71. package/src/frameworks/react/add-ons/shopify/assets/src/hooks/use-customer.ts.ejs +22 -0
  72. package/src/frameworks/react/add-ons/shopify/assets/src/integrations/shopify/header-cart.tsx +37 -0
  73. package/src/frameworks/react/add-ons/shopify/assets/src/lib/shopify/customer-queries.ts.ejs +228 -0
  74. package/src/frameworks/react/add-ons/shopify/assets/src/lib/shopify/format.ts +33 -0
  75. package/src/frameworks/react/add-ons/shopify/assets/src/lib/shopify/queries.ts +684 -0
  76. package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.addresses.tsx.ejs +67 -0
  77. package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.callback.tsx.ejs +45 -0
  78. package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.index.tsx.ejs +70 -0
  79. package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.login.tsx.ejs +59 -0
  80. package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.logout.tsx.ejs +16 -0
  81. package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.orders.$id.tsx.ejs +126 -0
  82. package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.orders.tsx.ejs +50 -0
  83. package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.tsx.ejs +34 -0
  84. package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.cart.tsx +45 -0
  85. package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.collections.$handle.tsx +66 -0
  86. package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.index.tsx +36 -0
  87. package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.pages.$handle.tsx +39 -0
  88. package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.policies.$handle.tsx +30 -0
  89. package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.products.$handle.tsx +106 -0
  90. package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.search.tsx +75 -0
  91. package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.tsx +78 -0
  92. package/src/frameworks/react/add-ons/shopify/assets/src/server/shopify/cart.functions.ts +207 -0
  93. package/src/frameworks/react/add-ons/shopify/assets/src/server/shopify/catalog.functions.ts +244 -0
  94. package/src/frameworks/react/add-ons/shopify/assets/src/server/shopify/cookies.ts +29 -0
  95. package/src/frameworks/react/add-ons/shopify/assets/src/server/shopify/customer-client.ts.ejs +99 -0
  96. package/src/frameworks/react/add-ons/shopify/assets/src/server/shopify/customer-cookies.ts.ejs +49 -0
  97. package/src/frameworks/react/add-ons/shopify/assets/src/server/shopify/customer.functions.ts.ejs +168 -0
  98. package/src/frameworks/react/add-ons/shopify/assets/src/server/shopify/env.ts +89 -0
  99. package/src/frameworks/react/add-ons/shopify/assets/src/server/shopify/oauth.ts.ejs +301 -0
  100. package/src/frameworks/react/add-ons/shopify/assets/src/server/shopify/storefront-client.ts +101 -0
  101. package/src/frameworks/react/add-ons/shopify/info.json +104 -0
  102. package/src/frameworks/react/add-ons/shopify/package.json +6 -0
  103. package/src/frameworks/react/add-ons/shopify/small-logo.svg +1 -0
  104. package/src/frameworks/react/examples/shopify-storefront/README.md +39 -0
  105. package/src/frameworks/react/examples/shopify-storefront/assets/src/components/FeaturedCollections.tsx +43 -0
  106. package/src/frameworks/react/examples/shopify-storefront/assets/src/components/ShopHero.tsx +39 -0
  107. package/src/frameworks/react/examples/shopify-storefront/assets/src/routes/index.tsx +65 -0
  108. package/src/frameworks/react/examples/shopify-storefront/info.json +18 -0
  109. package/src/frameworks/react/examples/shopify-storefront/package.json +3 -0
  110. package/src/frameworks/react/project/base/src/components/Header.tsx.ejs +34 -34
package/CHANGELOG.md CHANGED
@@ -1,5 +1,69 @@
1
1
  # @tanstack/create
2
2
 
3
+ ## 0.66.0
4
+
5
+ ### Minor Changes
6
+
7
+ - feat(cli, create): add Shopify storefront add-on + storefront template ([`814d222`](https://github.com/TanStack/cli/commit/814d222ac04e839eabe56abce5dcbe66d751c5d8))
8
+
9
+ Headless Shopify support for TanStack Start apps, scaffold-ready and
10
+ runtime-portable. The pitch: prove that TanStack Start is a first-class
11
+ target for Shopify, not just Next.js Commerce or Hydrogen.
12
+
13
+ **`shopify` add-on** — additive. `tanstack add shopify` mounts `/shop/*`
14
+ routes alongside an existing app without touching the home page. Includes:
15
+
16
+ - Storefront API client (server-only fetch via `createServerFn`, public
17
+ token by default + optional private token for higher rate limits and
18
+ buyer-IP forwarding).
19
+ - Hand-written GraphQL queries with hydrogen-react types (type-only;
20
+ zero runtime weight).
21
+ - httpOnly cookie cart (`tanstack_cart_id`) + React Query single-key
22
+ cache + optimistic updates with module-level mutation counter to
23
+ batch invalidations during rapid clicks.
24
+ - Hydrogen-demo parity routes: shop landing, product detail (with
25
+ variants + availability), collections, cart, search, Shopify CMS
26
+ pages, policies.
27
+ - Hydrogen-stock UI components (ProductCard, VariantSelector,
28
+ AddToCartButton, CartLineItem, CartSummary, ShopImage with CDN
29
+ transforms, Money via Intl) themed with six CSS custom properties
30
+ for easy reskinning.
31
+ - Header cart-count badge via the `header-user` integration slot.
32
+ - Shopify-hosted checkout (redirect to `cart.checkoutUrl`).
33
+ - **Optional Customer Account API** behind a `customerAccount` select
34
+ option. Hand-rolled OAuth 2.1 PKCE with `.well-known` discovery
35
+ cached in module memory (no usable npm client exists yet),
36
+ HMAC-signed httpOnly session cookies (HS256), lazy token refresh,
37
+ account dashboard / orders / order detail / addresses routes — all
38
+ EJS-guarded so the files only emit when enabled.
39
+
40
+ **`shopify-storefront` template** — storefront-first.
41
+ `tanstack create my-shop --template shopify-storefront` cascades the
42
+ `shopify` add-on (which cascades `tanstack-query`) and replaces the
43
+ home route with a polished landing (hero + featured collections + best
44
+ sellers grid).
45
+
46
+ **Zero-config first run.** Defaults to Shopify's public Hydrogen demo
47
+ store (`hydrogen-preview.myshopify.com`) so the storefront renders
48
+ real products immediately. Override the four env vars in `.env.local`
49
+ (or your deploy target's dashboard) to point at your store. Demo
50
+ defaults are baked into source as fallbacks, so the experience doesn't
51
+ break when a runtime doesn't load `.env` files into `process.env`.
52
+
53
+ **Portable.** Cookie ops via `@tanstack/react-start/server`; crypto via
54
+ Web Crypto (`crypto.subtle`); generic `CDN-Cache-Control` for browse
55
+ (`s-maxage=300, stale-while-revalidate=600`) and `private, no-store`
56
+ for cart. Works on Node, Cloudflare Workers, Shopify Oxygen (just
57
+ Workers), Vercel, Netlify, Bun, Deno.
58
+
59
+ **Header layout fix.** While the cart-count badge is the new
60
+ right-aligned action, the base scaffold's `Header` was placing the
61
+ social icons left-of-center on `sm+`. Reordered the JSX so navigation
62
+ sits between the logo and the right-side actions in DOM order, with
63
+ one mobile-only `order-3` to keep `flex-wrap` putting nav on its own
64
+ row. Result: logo → nav → (auto-spaced) → cart/social/theme on every
65
+ breakpoint, and a more sensible reading order for screen readers.
66
+
3
67
  ## 0.65.0
4
68
 
5
69
  ### Minor Changes
@@ -0,0 +1,86 @@
1
+ # Shopify
2
+
3
+ Headless Shopify storefront for TanStack Start. Mounts `/shop/*` routes
4
+ alongside your existing app — your home page stays untouched.
5
+
6
+ The default `.env.local` points at Shopify's public Hydrogen demo store, so the
7
+ storefront renders real products on first run with zero setup.
8
+
9
+ ## Routes
10
+
11
+ | Route | What it does |
12
+ |--------------------------------|---------------------------------------------|
13
+ | `/shop` | Shop landing — featured products + collections |
14
+ | `/shop/products/$handle` | Product detail (variants, images, options) |
15
+ | `/shop/collections/$handle` | Collection grid with sort + pagination |
16
+ | `/shop/cart` | Cart line items, discount codes, checkout |
17
+ | `/shop/search` | Product search |
18
+ | `/shop/pages/$handle` | Shopify CMS pages (about, etc.) |
19
+ | `/shop/policies/$handle` | Privacy, refund, terms, shipping |
20
+
21
+ If you opted into customer accounts during scaffold:
22
+
23
+ | Route | What it does |
24
+ |--------------------------------------|------------------------------------|
25
+ | `/shop/account/login` | Kick off Shopify OAuth |
26
+ | `/shop/account/callback` | OAuth callback handler |
27
+ | `/shop/account/logout` | End the customer session |
28
+ | `/shop/account` | Dashboard |
29
+ | `/shop/account/orders` | Order history |
30
+ | `/shop/account/orders/$id` | Order detail |
31
+ | `/shop/account/addresses` | Manage saved addresses |
32
+
33
+ ## Connect your store
34
+
35
+ 1. In Shopify admin, go to **Settings > Apps and sales channels > Develop apps**.
36
+ 2. Create a new app, enable the **Storefront API**, and copy the public access token.
37
+ 3. Set in `.env.local`:
38
+ ```
39
+ SHOPIFY_STORE_DOMAIN=your-store.myshopify.com
40
+ SHOPIFY_PUBLIC_STOREFRONT_TOKEN=...
41
+ ```
42
+ 4. (Optional) For higher rate limits + buyer-IP forwarding, also create a private
43
+ token and set `SHOPIFY_PRIVATE_STOREFRONT_TOKEN`.
44
+
45
+ ## Enable customer accounts
46
+
47
+ If `customerAccount=enabled` was selected during scaffold:
48
+
49
+ 1. In Shopify admin, go to **Settings > Customer accounts > Headless**.
50
+ 2. Register a public client. Add `http://localhost:3000/shop/account/callback`
51
+ *and* your production callback URL to the redirect URIs.
52
+ 3. Copy the Client ID and Shop ID into `.env.local`:
53
+ ```
54
+ SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID=...
55
+ SHOPIFY_CUSTOMER_ACCOUNT_SHOP_ID=...
56
+ SHOPIFY_SESSION_SECRET=$(openssl rand -hex 32)
57
+ ```
58
+
59
+ The Hydrogen demo store doesn't have customer accounts configured, so the
60
+ default demo creds won't work for `/shop/account/*` — you'll need a real store.
61
+
62
+ ## Architecture
63
+
64
+ - **Storefront API client** — server-only fetch in `src/server/shopify/storefront-client.ts`.
65
+ All product/cart reads go through the server (private token never reaches the browser).
66
+ - **Cart state** — Cart ID stored in an httpOnly cookie (`tanstack_cart_id`). React
67
+ Query owns the cache (single key `['shopify', 'cart']`); optimistic updates with
68
+ a module-level mutation counter to batch invalidations.
69
+ - **GraphQL queries** — hand-written strings in `src/lib/shopify/queries.ts`, types
70
+ sliced from `@shopify/hydrogen-react/storefront-api-types` (type-only import; zero runtime).
71
+ - **Customer accounts** — hand-rolled OAuth 2.1 PKCE with `.well-known` discovery
72
+ (no usable npm client exists yet). Tokens in a signed httpOnly cookie.
73
+ - **Checkout** — redirects to `cart.checkoutUrl` (Shopify-hosted).
74
+
75
+ ## Deployment
76
+
77
+ Works anywhere TanStack Start runs:
78
+
79
+ - **Node** — `npm run build && npm start`
80
+ - **Cloudflare Workers / Shopify Oxygen** — Oxygen is just Workers under the hood;
81
+ build with the Workers preset and deploy to either platform.
82
+ - **Vercel / Netlify** — set the env vars in the dashboard.
83
+ - **Bun, Deno** — supported via Start's adapters.
84
+
85
+ For the customer-account flow, register both your local *and* production
86
+ callback URLs in the Shopify admin's headless app config.
@@ -0,0 +1,19 @@
1
+ # ─── Shopify ─────────────────────────────────────────────────────────────────
2
+ # These defaults point to Shopify's public Hydrogen demo store so the storefront
3
+ # works on first run with zero config. Replace with your store's values to go live.
4
+ # Get tokens at: Shopify admin > Settings > Apps and sales channels > Develop apps.
5
+ SHOPIFY_STORE_DOMAIN=hydrogen-preview.myshopify.com
6
+ SHOPIFY_STOREFRONT_API_VERSION=2026-01
7
+ SHOPIFY_PUBLIC_STOREFRONT_TOKEN=3b580e70970c4528da70c98e097c2fa0
8
+ # Optional — set to a private storefront token for higher rate limits + buyer-IP forwarding.
9
+ SHOPIFY_PRIVATE_STOREFRONT_TOKEN=
10
+
11
+ # ─── Shopify Customer Account API ────────────────────────────────────────────
12
+ # Required only if you opted into customer accounts during scaffold.
13
+ # Register a public client in Shopify admin > Settings > Customer accounts > Headless,
14
+ # then paste the client ID and your numeric shop ID below.
15
+ SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID=
16
+ SHOPIFY_CUSTOMER_ACCOUNT_SHOP_ID=
17
+ SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI=http://localhost:3000/shop/account/callback
18
+ # 32+ random chars, e.g. `openssl rand -hex 32`. Used to sign customer session cookies.
19
+ SHOPIFY_SESSION_SECRET=
@@ -0,0 +1,41 @@
1
+ <% if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } %>
2
+ import { Link } from '@tanstack/react-router'
3
+
4
+ const ITEMS = [
5
+ { to: '/shop/account', label: 'Overview', exact: true },
6
+ { to: '/shop/account/orders', label: 'Orders', exact: false },
7
+ { to: '/shop/account/addresses', label: 'Addresses', exact: false },
8
+ ] as const
9
+
10
+ export function AccountNav() {
11
+ return (
12
+ <nav className="flex flex-col gap-1 text-sm">
13
+ {ITEMS.map((item) => (
14
+ <Link
15
+ key={item.to}
16
+ to={item.to}
17
+ activeOptions={{ exact: item.exact }}
18
+ className="rounded-md px-2 py-1.5 text-[var(--storefront-fg-muted)] hover:bg-[var(--storefront-line)]/40 hover:text-[var(--storefront-fg)]"
19
+ activeProps={{
20
+ className:
21
+ 'rounded-md px-2 py-1.5 bg-[var(--storefront-line)]/60 text-[var(--storefront-fg)] font-medium',
22
+ }}
23
+ >
24
+ {item.label}
25
+ </Link>
26
+ ))}
27
+ <form
28
+ action="/shop/account/logout"
29
+ method="post"
30
+ className="mt-4 border-t border-[var(--storefront-line)] pt-4"
31
+ >
32
+ <button
33
+ type="submit"
34
+ className="rounded-md px-2 py-1.5 text-left text-sm text-[var(--storefront-fg-muted)] hover:text-[var(--storefront-fg)]"
35
+ >
36
+ Sign out
37
+ </button>
38
+ </form>
39
+ </nav>
40
+ )
41
+ }
@@ -0,0 +1,48 @@
1
+ import { useAddToCart } from '#/hooks/use-cart'
2
+ import type { ProductDetail, ProductDetailVariant } from '#/lib/shopify/queries'
3
+
4
+ type AddToCartButtonProps = {
5
+ product: ProductDetail
6
+ variant: ProductDetailVariant | undefined
7
+ quantity?: number
8
+ }
9
+
10
+ export function AddToCartButton({
11
+ product,
12
+ variant,
13
+ quantity = 1,
14
+ }: AddToCartButtonProps) {
15
+ const { mutate, isPending } = useAddToCart()
16
+ const disabled = !variant || !variant.availableForSale || isPending
17
+
18
+ return (
19
+ <button
20
+ type="button"
21
+ disabled={disabled}
22
+ onClick={() => {
23
+ if (!variant) return
24
+ mutate({
25
+ variantId: variant.id,
26
+ quantity,
27
+ line: {
28
+ productTitle: product.title,
29
+ productHandle: product.handle,
30
+ variantTitle: variant.title,
31
+ price: variant.price,
32
+ image: variant.image ?? product.images.nodes[0] ?? null,
33
+ selectedOptions: variant.selectedOptions,
34
+ },
35
+ })
36
+ }}
37
+ className="w-full rounded-full bg-[var(--storefront-accent)] px-6 py-3.5 text-sm font-medium text-[var(--storefront-accent-fg)] transition disabled:cursor-not-allowed disabled:opacity-50 hover:opacity-90"
38
+ >
39
+ {!variant
40
+ ? 'Select options'
41
+ : !variant.availableForSale
42
+ ? 'Sold out'
43
+ : isPending
44
+ ? 'Adding…'
45
+ : 'Add to cart'}
46
+ </button>
47
+ )
48
+ }
@@ -0,0 +1,94 @@
1
+ import { Link } from '@tanstack/react-router'
2
+
3
+ import { Money } from '#/components/shop/money'
4
+ import { ShopImage } from '#/components/shop/shop-image'
5
+ import { useRemoveCartLine, useUpdateCartLine } from '#/hooks/use-cart'
6
+ import type { CartLineDetail } from '#/lib/shopify/queries'
7
+
8
+ type CartLineItemProps = {
9
+ line: CartLineDetail
10
+ }
11
+
12
+ export function CartLineItem({ line }: CartLineItemProps) {
13
+ const update = useUpdateCartLine()
14
+ const remove = useRemoveCartLine()
15
+ const merch = line.merchandise
16
+
17
+ return (
18
+ <li className="flex gap-4 border-b border-[var(--storefront-line)] py-6">
19
+ <Link
20
+ to="/shop/products/$handle"
21
+ params={{ handle: merch.product.handle }}
22
+ className="flex-shrink-0"
23
+ >
24
+ <ShopImage
25
+ src={merch.image?.url}
26
+ alt={merch.image?.altText ?? merch.product.title}
27
+ width={120}
28
+ height={150}
29
+ className="h-[150px] w-[120px] rounded-md object-cover"
30
+ />
31
+ </Link>
32
+ <div className="flex flex-1 flex-col gap-2">
33
+ <div className="flex items-start justify-between gap-4">
34
+ <div>
35
+ <Link
36
+ to="/shop/products/$handle"
37
+ params={{ handle: merch.product.handle }}
38
+ className="text-base font-medium no-underline text-[var(--storefront-fg)]"
39
+ >
40
+ {merch.product.title}
41
+ </Link>
42
+ {merch.title && merch.title !== 'Default Title' && (
43
+ <p className="text-sm text-[var(--storefront-fg-muted)]">
44
+ {merch.selectedOptions.map((o) => o.value).join(' · ')}
45
+ </p>
46
+ )}
47
+ </div>
48
+ <Money
49
+ amount={line.cost.totalAmount.amount}
50
+ currencyCode={line.cost.totalAmount.currencyCode}
51
+ className="text-base font-medium"
52
+ />
53
+ </div>
54
+ <div className="mt-auto flex items-center justify-between gap-4">
55
+ <div className="inline-flex items-center rounded-full border border-[var(--storefront-line)]">
56
+ <button
57
+ type="button"
58
+ aria-label="Decrease quantity"
59
+ disabled={update.isPending || line.quantity <= 1}
60
+ onClick={() =>
61
+ update.mutate({ lineId: line.id, quantity: line.quantity - 1 })
62
+ }
63
+ className="px-3 py-1.5 text-base disabled:opacity-40"
64
+ >
65
+
66
+ </button>
67
+ <span className="min-w-[2rem] text-center text-sm">
68
+ {line.quantity}
69
+ </span>
70
+ <button
71
+ type="button"
72
+ aria-label="Increase quantity"
73
+ disabled={update.isPending}
74
+ onClick={() =>
75
+ update.mutate({ lineId: line.id, quantity: line.quantity + 1 })
76
+ }
77
+ className="px-3 py-1.5 text-base disabled:opacity-40"
78
+ >
79
+ +
80
+ </button>
81
+ </div>
82
+ <button
83
+ type="button"
84
+ onClick={() => remove.mutate({ lineId: line.id })}
85
+ disabled={remove.isPending}
86
+ className="text-sm text-[var(--storefront-fg-muted)] underline underline-offset-4 hover:text-[var(--storefront-fg)] disabled:opacity-40"
87
+ >
88
+ Remove
89
+ </button>
90
+ </div>
91
+ </div>
92
+ </li>
93
+ )
94
+ }
@@ -0,0 +1,111 @@
1
+ import { useState } from 'react'
2
+
3
+ import { Money } from '#/components/shop/money'
4
+ import {
5
+ useApplyDiscountCode,
6
+ useRemoveDiscountCode,
7
+ } from '#/hooks/use-cart'
8
+ import type { CartDetail } from '#/lib/shopify/queries'
9
+
10
+ type CartSummaryProps = {
11
+ cart: CartDetail
12
+ }
13
+
14
+ export function CartSummary({ cart }: CartSummaryProps) {
15
+ const [code, setCode] = useState('')
16
+ const apply = useApplyDiscountCode()
17
+ const remove = useRemoveDiscountCode()
18
+ const appliedCode = cart.discountCodes.find((c) => c.applicable)
19
+
20
+ return (
21
+ <aside className="flex flex-col gap-4 rounded-2xl border border-[var(--storefront-line)] p-6">
22
+ <h2 className="text-lg font-medium">Order summary</h2>
23
+
24
+ <div className="flex justify-between text-sm">
25
+ <span>Subtotal</span>
26
+ <Money
27
+ amount={cart.cost.subtotalAmount.amount}
28
+ currencyCode={cart.cost.subtotalAmount.currencyCode}
29
+ />
30
+ </div>
31
+ {cart.cost.totalTaxAmount && Number(cart.cost.totalTaxAmount.amount) > 0 && (
32
+ <div className="flex justify-between text-sm text-[var(--storefront-fg-muted)]">
33
+ <span>Estimated tax</span>
34
+ <Money
35
+ amount={cart.cost.totalTaxAmount.amount}
36
+ currencyCode={cart.cost.totalTaxAmount.currencyCode}
37
+ />
38
+ </div>
39
+ )}
40
+ <p className="text-sm text-[var(--storefront-fg-muted)]">
41
+ Shipping calculated at checkout.
42
+ </p>
43
+
44
+ <form
45
+ onSubmit={(e) => {
46
+ e.preventDefault()
47
+ if (code.trim()) {
48
+ apply.mutate(
49
+ { code: code.trim() },
50
+ { onSuccess: () => setCode('') },
51
+ )
52
+ }
53
+ }}
54
+ className="flex flex-col gap-2"
55
+ >
56
+ <div className="flex gap-2">
57
+ <input
58
+ type="text"
59
+ value={code}
60
+ onChange={(e) => setCode(e.target.value)}
61
+ placeholder="Discount code"
62
+ className="min-w-0 flex-1 rounded-full border border-[var(--storefront-line)] bg-transparent px-4 py-2 text-sm focus:border-[var(--storefront-accent)] focus:outline-none"
63
+ />
64
+ <button
65
+ type="submit"
66
+ disabled={apply.isPending || !code.trim()}
67
+ className="rounded-full border border-[var(--storefront-line)] px-4 py-2 text-sm font-medium hover:border-[var(--storefront-accent)] disabled:opacity-40"
68
+ >
69
+ Apply
70
+ </button>
71
+ </div>
72
+ {apply.error && (
73
+ <p className="text-xs text-red-600">{(apply.error as Error).message}</p>
74
+ )}
75
+ {appliedCode && (
76
+ <div className="flex items-center justify-between rounded-full bg-[var(--storefront-line)]/40 px-4 py-2 text-xs">
77
+ <span>
78
+ Applied: <strong>{appliedCode.code}</strong>
79
+ </span>
80
+ <button
81
+ type="button"
82
+ onClick={() => remove.mutate()}
83
+ disabled={remove.isPending}
84
+ className="underline underline-offset-2 disabled:opacity-40"
85
+ >
86
+ Remove
87
+ </button>
88
+ </div>
89
+ )}
90
+ </form>
91
+
92
+ <div className="flex justify-between border-t border-[var(--storefront-line)] pt-4 text-base font-medium">
93
+ <span>Total</span>
94
+ <Money
95
+ amount={cart.cost.totalAmount.amount}
96
+ currencyCode={cart.cost.totalAmount.currencyCode}
97
+ />
98
+ </div>
99
+
100
+ <a
101
+ href={cart.checkoutUrl}
102
+ className="block w-full rounded-full bg-[var(--storefront-accent)] px-6 py-3.5 text-center text-sm font-medium text-[var(--storefront-accent-fg)] no-underline transition hover:opacity-90"
103
+ >
104
+ Checkout
105
+ </a>
106
+ <p className="text-center text-xs text-[var(--storefront-fg-muted)]">
107
+ Secure checkout powered by Shopify
108
+ </p>
109
+ </aside>
110
+ )
111
+ }
@@ -0,0 +1,29 @@
1
+ import { Link } from '@tanstack/react-router'
2
+ import type { ReactNode } from 'react'
3
+
4
+ type EmptyStateProps = {
5
+ title: string
6
+ description?: string
7
+ cta?: { label: string; to: string }
8
+ children?: ReactNode
9
+ }
10
+
11
+ export function EmptyState({ title, description, cta, children }: EmptyStateProps) {
12
+ return (
13
+ <div className="flex flex-col items-center gap-4 rounded-2xl border border-dashed border-[var(--storefront-line)] px-6 py-16 text-center">
14
+ <h2 className="text-xl font-medium">{title}</h2>
15
+ {description && (
16
+ <p className="max-w-md text-[var(--storefront-fg-muted)]">{description}</p>
17
+ )}
18
+ {children}
19
+ {cta && (
20
+ <Link
21
+ to={cta.to}
22
+ className="rounded-full border border-[var(--storefront-fg)] px-5 py-2 text-sm font-medium no-underline text-[var(--storefront-fg)]"
23
+ >
24
+ {cta.label}
25
+ </Link>
26
+ )}
27
+ </div>
28
+ )
29
+ }
@@ -0,0 +1,11 @@
1
+ import { formatMoney } from '#/lib/shopify/format'
2
+
3
+ type MoneyProps = {
4
+ amount: string | number
5
+ currencyCode: string
6
+ className?: string
7
+ }
8
+
9
+ export function Money({ amount, currencyCode, className }: MoneyProps) {
10
+ return <span className={className}>{formatMoney(amount, currencyCode)}</span>
11
+ }
@@ -0,0 +1,74 @@
1
+ import { Link } from '@tanstack/react-router'
2
+
3
+ import { Money } from '#/components/shop/money'
4
+ import { ShopImage } from '#/components/shop/shop-image'
5
+ import type { ProductListItem } from '#/lib/shopify/queries'
6
+
7
+ type ProductCardProps = {
8
+ product: ProductListItem
9
+ }
10
+
11
+ export function ProductCard({ product }: ProductCardProps) {
12
+ const minPrice = product.priceRange.minVariantPrice
13
+ const maxPrice = product.priceRange.maxVariantPrice
14
+ const compareAt = product.compareAtPriceRange.minVariantPrice
15
+ const onSale =
16
+ Number(compareAt.amount) > 0 &&
17
+ Number(compareAt.amount) > Number(minPrice.amount)
18
+ const soldOut = !product.variants.nodes.some((v) => v.availableForSale)
19
+
20
+ return (
21
+ <Link
22
+ to="/shop/products/$handle"
23
+ params={{ handle: product.handle }}
24
+ className="group flex flex-col gap-3 no-underline text-[var(--storefront-fg)]"
25
+ >
26
+ <div
27
+ className="relative overflow-hidden bg-[var(--storefront-line)]"
28
+ style={{ aspectRatio: '4 / 5', borderRadius: 'var(--storefront-radius)' }}
29
+ >
30
+ <ShopImage
31
+ src={product.featuredImage?.url}
32
+ alt={product.featuredImage?.altText ?? product.title}
33
+ width={600}
34
+ height={750}
35
+ sizes="(min-width: 1024px) 25vw, (min-width: 640px) 33vw, 50vw"
36
+ className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
37
+ />
38
+ {soldOut && (
39
+ <span className="absolute left-3 top-3 rounded-full bg-[var(--storefront-bg)]/90 px-2.5 py-1 text-xs font-medium uppercase tracking-wide">
40
+ Sold out
41
+ </span>
42
+ )}
43
+ {!soldOut && onSale && (
44
+ <span className="absolute left-3 top-3 rounded-full bg-[var(--storefront-accent)] px-2.5 py-1 text-xs font-medium uppercase tracking-wide text-[var(--storefront-accent-fg)]">
45
+ Sale
46
+ </span>
47
+ )}
48
+ </div>
49
+ <div className="flex flex-col gap-1">
50
+ <h3 className="text-sm font-medium leading-snug">{product.title}</h3>
51
+ <p className="text-sm text-[var(--storefront-fg-muted)]">
52
+ {onSale && (
53
+ <span className="mr-2 line-through">
54
+ <Money
55
+ amount={compareAt.amount}
56
+ currencyCode={compareAt.currencyCode}
57
+ />
58
+ </span>
59
+ )}
60
+ <Money amount={minPrice.amount} currencyCode={minPrice.currencyCode} />
61
+ {Number(maxPrice.amount) > Number(minPrice.amount) && (
62
+ <>
63
+ {' – '}
64
+ <Money
65
+ amount={maxPrice.amount}
66
+ currencyCode={maxPrice.currencyCode}
67
+ />
68
+ </>
69
+ )}
70
+ </p>
71
+ </div>
72
+ </Link>
73
+ )
74
+ }
@@ -0,0 +1,24 @@
1
+ import { ProductCard } from '#/components/shop/product-card'
2
+ import type { ProductListItem } from '#/lib/shopify/queries'
3
+
4
+ type ProductGridProps = {
5
+ products: ReadonlyArray<ProductListItem>
6
+ }
7
+
8
+ export function ProductGrid({ products }: ProductGridProps) {
9
+ if (products.length === 0) {
10
+ return (
11
+ <p className="py-16 text-center text-[var(--storefront-fg-muted)]">
12
+ No products yet.
13
+ </p>
14
+ )
15
+ }
16
+
17
+ return (
18
+ <div className="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-3 lg:grid-cols-4">
19
+ {products.map((product) => (
20
+ <ProductCard key={product.id} product={product} />
21
+ ))}
22
+ </div>
23
+ )
24
+ }
@@ -0,0 +1,57 @@
1
+ import { shopifyImageUrl } from '#/lib/shopify/format'
2
+
3
+ type ShopImageProps = {
4
+ src: string | null | undefined
5
+ alt: string | null | undefined
6
+ width: number
7
+ height?: number
8
+ className?: string
9
+ sizes?: string
10
+ loading?: 'eager' | 'lazy'
11
+ }
12
+
13
+ export function ShopImage({
14
+ src,
15
+ alt,
16
+ width,
17
+ height,
18
+ className,
19
+ sizes,
20
+ loading = 'lazy',
21
+ }: ShopImageProps) {
22
+ if (!src) {
23
+ return (
24
+ <div
25
+ aria-hidden
26
+ className={className}
27
+ style={{
28
+ background: 'var(--storefront-line)',
29
+ aspectRatio: height ? `${width} / ${height}` : '1',
30
+ }}
31
+ />
32
+ )
33
+ }
34
+
35
+ const transformed = shopifyImageUrl(src, { width, height, format: 'webp' })
36
+ const srcSet = [1, 2]
37
+ .map((dpr) => {
38
+ const w = width * dpr
39
+ const h = height ? height * dpr : undefined
40
+ return `${shopifyImageUrl(src, { width: w, height: h, format: 'webp' })} ${dpr}x`
41
+ })
42
+ .join(', ')
43
+
44
+ return (
45
+ <img
46
+ src={transformed}
47
+ srcSet={srcSet}
48
+ sizes={sizes}
49
+ alt={alt ?? ''}
50
+ width={width}
51
+ height={height}
52
+ loading={loading}
53
+ decoding="async"
54
+ className={className}
55
+ />
56
+ )
57
+ }