@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.
- package/CHANGELOG.md +64 -0
- package/dist/frameworks/react/add-ons/shopify/README.md +86 -0
- package/dist/frameworks/react/add-ons/shopify/assets/_dot_env.local.append +19 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/account-nav.tsx.ejs +41 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/add-to-cart-button.tsx +48 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/cart-line-item.tsx +94 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/cart-summary.tsx +111 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/empty-state.tsx +29 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/money.tsx +11 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/product-card.tsx +74 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/product-grid.tsx +24 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/shop-image.tsx +57 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/shop.css +58 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/components/shop/variant-selector.tsx +79 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/hooks/use-cart.ts +276 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/hooks/use-customer.ts.ejs +22 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/integrations/shopify/header-cart.tsx +37 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/lib/shopify/customer-queries.ts.ejs +228 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/lib/shopify/format.ts +33 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/lib/shopify/queries.ts +684 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.addresses.tsx.ejs +67 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.callback.tsx.ejs +45 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.index.tsx.ejs +70 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.login.tsx.ejs +59 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.logout.tsx.ejs +16 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.orders.$id.tsx.ejs +126 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.orders.tsx.ejs +50 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.tsx.ejs +34 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.cart.tsx +45 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.collections.$handle.tsx +66 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.index.tsx +36 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.pages.$handle.tsx +39 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.policies.$handle.tsx +30 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.products.$handle.tsx +106 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.search.tsx +75 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/routes/shop.tsx +78 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/server/shopify/cart.functions.ts +207 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/server/shopify/catalog.functions.ts +244 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/server/shopify/cookies.ts +29 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/server/shopify/customer-client.ts.ejs +99 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/server/shopify/customer-cookies.ts.ejs +49 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/server/shopify/customer.functions.ts.ejs +168 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/server/shopify/env.ts +89 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/server/shopify/oauth.ts.ejs +301 -0
- package/dist/frameworks/react/add-ons/shopify/assets/src/server/shopify/storefront-client.ts +101 -0
- package/dist/frameworks/react/add-ons/shopify/info.json +104 -0
- package/dist/frameworks/react/add-ons/shopify/package.json +6 -0
- package/dist/frameworks/react/add-ons/shopify/small-logo.svg +1 -0
- package/dist/frameworks/react/examples/shopify-storefront/README.md +39 -0
- package/dist/frameworks/react/examples/shopify-storefront/assets/src/components/FeaturedCollections.tsx +43 -0
- package/dist/frameworks/react/examples/shopify-storefront/assets/src/components/ShopHero.tsx +39 -0
- package/dist/frameworks/react/examples/shopify-storefront/assets/src/routes/index.tsx +65 -0
- package/dist/frameworks/react/examples/shopify-storefront/info.json +18 -0
- package/dist/frameworks/react/examples/shopify-storefront/package.json +3 -0
- package/dist/frameworks/react/project/base/src/components/Header.tsx.ejs +34 -34
- package/package.json +1 -1
- package/src/frameworks/react/add-ons/shopify/README.md +86 -0
- package/src/frameworks/react/add-ons/shopify/assets/_dot_env.local.append +19 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/account-nav.tsx.ejs +41 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/add-to-cart-button.tsx +48 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/cart-line-item.tsx +94 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/cart-summary.tsx +111 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/empty-state.tsx +29 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/money.tsx +11 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/product-card.tsx +74 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/product-grid.tsx +24 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/shop-image.tsx +57 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/shop.css +58 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/components/shop/variant-selector.tsx +79 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/hooks/use-cart.ts +276 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/hooks/use-customer.ts.ejs +22 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/integrations/shopify/header-cart.tsx +37 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/lib/shopify/customer-queries.ts.ejs +228 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/lib/shopify/format.ts +33 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/lib/shopify/queries.ts +684 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.addresses.tsx.ejs +67 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.callback.tsx.ejs +45 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.index.tsx.ejs +70 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.login.tsx.ejs +59 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.logout.tsx.ejs +16 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.orders.$id.tsx.ejs +126 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.orders.tsx.ejs +50 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.account.tsx.ejs +34 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.cart.tsx +45 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.collections.$handle.tsx +66 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.index.tsx +36 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.pages.$handle.tsx +39 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.policies.$handle.tsx +30 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.products.$handle.tsx +106 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.search.tsx +75 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/routes/shop.tsx +78 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/server/shopify/cart.functions.ts +207 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/server/shopify/catalog.functions.ts +244 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/server/shopify/cookies.ts +29 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/server/shopify/customer-client.ts.ejs +99 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/server/shopify/customer-cookies.ts.ejs +49 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/server/shopify/customer.functions.ts.ejs +168 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/server/shopify/env.ts +89 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/server/shopify/oauth.ts.ejs +301 -0
- package/src/frameworks/react/add-ons/shopify/assets/src/server/shopify/storefront-client.ts +101 -0
- package/src/frameworks/react/add-ons/shopify/info.json +104 -0
- package/src/frameworks/react/add-ons/shopify/package.json +6 -0
- package/src/frameworks/react/add-ons/shopify/small-logo.svg +1 -0
- package/src/frameworks/react/examples/shopify-storefront/README.md +39 -0
- package/src/frameworks/react/examples/shopify-storefront/assets/src/components/FeaturedCollections.tsx +43 -0
- package/src/frameworks/react/examples/shopify-storefront/assets/src/components/ShopHero.tsx +39 -0
- package/src/frameworks/react/examples/shopify-storefront/assets/src/routes/index.tsx +65 -0
- package/src/frameworks/react/examples/shopify-storefront/info.json +18 -0
- package/src/frameworks/react/examples/shopify-storefront/package.json +3 -0
- package/src/frameworks/react/project/base/src/components/Header.tsx.ejs +34 -34
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
<% if (addOnOption.shopify.customerAccount !== 'enabled') { ignoreFile(); return; } %>
|
|
2
|
+
import { getCustomerEnv } from '#/server/shopify/env'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* OAuth 2.1 Authorization Code + PKCE flow for the Shopify Customer Account
|
|
6
|
+
* API. We're a public client (browser + Start backend), so no client secret —
|
|
7
|
+
* security comes from the PKCE verifier proof.
|
|
8
|
+
*
|
|
9
|
+
* No usable npm client exists for Customer Account API as of 2026-05; this is
|
|
10
|
+
* a hand-rolled implementation using Web APIs only (portable across Node,
|
|
11
|
+
* Workers, Vercel, Netlify, Bun, Deno).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const SCOPE = 'openid email customer-account-api:full'
|
|
15
|
+
|
|
16
|
+
type DiscoveryDocument = {
|
|
17
|
+
authorizationEndpoint: string
|
|
18
|
+
tokenEndpoint: string
|
|
19
|
+
endSessionEndpoint: string
|
|
20
|
+
graphqlEndpoint: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let discoveryCache: DiscoveryDocument | null = null
|
|
24
|
+
|
|
25
|
+
async function fetchDiscovery(): Promise<DiscoveryDocument> {
|
|
26
|
+
if (discoveryCache) return discoveryCache
|
|
27
|
+
const env = getCustomerEnv()
|
|
28
|
+
const base = `https://shopify.com/${env.SHOPIFY_CUSTOMER_ACCOUNT_SHOP_ID}`
|
|
29
|
+
const [oidcRes, customerRes] = await Promise.all([
|
|
30
|
+
fetch(`${base}/.well-known/openid-configuration`),
|
|
31
|
+
fetch(`${base}/.well-known/customer-account-api`),
|
|
32
|
+
])
|
|
33
|
+
if (!oidcRes.ok || !customerRes.ok) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`Customer Account discovery failed (${oidcRes.status} / ${customerRes.status}). Verify SHOPIFY_CUSTOMER_ACCOUNT_SHOP_ID.`,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
const oidc = (await oidcRes.json()) as {
|
|
39
|
+
authorization_endpoint: string
|
|
40
|
+
token_endpoint: string
|
|
41
|
+
end_session_endpoint: string
|
|
42
|
+
}
|
|
43
|
+
const customer = (await customerRes.json()) as {
|
|
44
|
+
graphql_api: { endpoint: string }
|
|
45
|
+
}
|
|
46
|
+
discoveryCache = {
|
|
47
|
+
authorizationEndpoint: oidc.authorization_endpoint,
|
|
48
|
+
tokenEndpoint: oidc.token_endpoint,
|
|
49
|
+
endSessionEndpoint: oidc.end_session_endpoint,
|
|
50
|
+
graphqlEndpoint: customer.graphql_api.endpoint,
|
|
51
|
+
}
|
|
52
|
+
return discoveryCache
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function getDiscovery() {
|
|
56
|
+
return fetchDiscovery()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* ─── Base64url + crypto helpers (Web Crypto only) ──────────────────────── */
|
|
60
|
+
|
|
61
|
+
function bufferToBase64Url(buffer: ArrayBuffer): string {
|
|
62
|
+
const bytes = new Uint8Array(buffer)
|
|
63
|
+
let str = ''
|
|
64
|
+
for (let i = 0; i < bytes.byteLength; i++) {
|
|
65
|
+
str += String.fromCharCode(bytes[i]!)
|
|
66
|
+
}
|
|
67
|
+
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function base64UrlEncode(input: string): string {
|
|
71
|
+
return bufferToBase64Url(new TextEncoder().encode(input).buffer as ArrayBuffer)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function base64UrlDecode(input: string): string {
|
|
75
|
+
const padded = input.replace(/-/g, '+').replace(/_/g, '/')
|
|
76
|
+
const padding = (4 - (padded.length % 4)) % 4
|
|
77
|
+
return atob(padded + '='.repeat(padding))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function randomBytes(n: number): Uint8Array {
|
|
81
|
+
const arr = new Uint8Array(n)
|
|
82
|
+
crypto.getRandomValues(arr)
|
|
83
|
+
return arr
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function randomString(byteLength: number): string {
|
|
87
|
+
return bufferToBase64Url(randomBytes(byteLength).buffer as ArrayBuffer)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function sha256(input: string): Promise<ArrayBuffer> {
|
|
91
|
+
return crypto.subtle.digest('SHA-256', new TextEncoder().encode(input))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* ─── PKCE ──────────────────────────────────────────────────────────────── */
|
|
95
|
+
|
|
96
|
+
export type PkcePair = { codeVerifier: string; codeChallenge: string }
|
|
97
|
+
|
|
98
|
+
export async function createPkcePair(): Promise<PkcePair> {
|
|
99
|
+
const codeVerifier = randomString(48)
|
|
100
|
+
const codeChallenge = bufferToBase64Url(await sha256(codeVerifier))
|
|
101
|
+
return { codeVerifier, codeChallenge }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function createState(): string {
|
|
105
|
+
return randomString(24)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* ─── Authorize / token / logout URLs ───────────────────────────────────── */
|
|
109
|
+
|
|
110
|
+
export async function buildAuthorizationUrl(input: {
|
|
111
|
+
state: string
|
|
112
|
+
codeChallenge: string
|
|
113
|
+
}): Promise<string> {
|
|
114
|
+
const env = getCustomerEnv()
|
|
115
|
+
const discovery = await fetchDiscovery()
|
|
116
|
+
const url = new URL(discovery.authorizationEndpoint)
|
|
117
|
+
url.searchParams.set('client_id', env.SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID)
|
|
118
|
+
url.searchParams.set('scope', SCOPE)
|
|
119
|
+
url.searchParams.set('response_type', 'code')
|
|
120
|
+
url.searchParams.set('redirect_uri', env.SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI)
|
|
121
|
+
url.searchParams.set('state', input.state)
|
|
122
|
+
url.searchParams.set('code_challenge', input.codeChallenge)
|
|
123
|
+
url.searchParams.set('code_challenge_method', 'S256')
|
|
124
|
+
return url.toString()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
type TokenResponse = {
|
|
128
|
+
access_token: string
|
|
129
|
+
refresh_token: string
|
|
130
|
+
id_token: string
|
|
131
|
+
expires_in: number
|
|
132
|
+
token_type: 'Bearer'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function exchangeCodeForTokens(input: {
|
|
136
|
+
code: string
|
|
137
|
+
codeVerifier: string
|
|
138
|
+
}): Promise<TokenResponse> {
|
|
139
|
+
const env = getCustomerEnv()
|
|
140
|
+
const discovery = await fetchDiscovery()
|
|
141
|
+
const body = new URLSearchParams({
|
|
142
|
+
grant_type: 'authorization_code',
|
|
143
|
+
client_id: env.SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID,
|
|
144
|
+
code: input.code,
|
|
145
|
+
redirect_uri: env.SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI,
|
|
146
|
+
code_verifier: input.codeVerifier,
|
|
147
|
+
})
|
|
148
|
+
const res = await fetch(discovery.tokenEndpoint, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: {
|
|
151
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
152
|
+
Origin: new URL(env.SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI).origin,
|
|
153
|
+
},
|
|
154
|
+
body,
|
|
155
|
+
})
|
|
156
|
+
if (!res.ok) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Token exchange failed: ${res.status} ${res.statusText} ${await res.text()}`,
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
return (await res.json()) as TokenResponse
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function refreshTokens(refreshToken: string): Promise<TokenResponse> {
|
|
165
|
+
const env = getCustomerEnv()
|
|
166
|
+
const discovery = await fetchDiscovery()
|
|
167
|
+
const body = new URLSearchParams({
|
|
168
|
+
grant_type: 'refresh_token',
|
|
169
|
+
client_id: env.SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID,
|
|
170
|
+
refresh_token: refreshToken,
|
|
171
|
+
})
|
|
172
|
+
const res = await fetch(discovery.tokenEndpoint, {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
headers: {
|
|
175
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
176
|
+
Origin: new URL(env.SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI).origin,
|
|
177
|
+
},
|
|
178
|
+
body,
|
|
179
|
+
})
|
|
180
|
+
if (!res.ok) {
|
|
181
|
+
throw new Error(`Token refresh failed: ${res.status} ${res.statusText}`)
|
|
182
|
+
}
|
|
183
|
+
return (await res.json()) as TokenResponse
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function buildEndSessionUrl(idToken: string): Promise<string> {
|
|
187
|
+
const env = getCustomerEnv()
|
|
188
|
+
const discovery = await fetchDiscovery()
|
|
189
|
+
const url = new URL(discovery.endSessionEndpoint)
|
|
190
|
+
url.searchParams.set('id_token_hint', idToken)
|
|
191
|
+
url.searchParams.set(
|
|
192
|
+
'post_logout_redirect_uri',
|
|
193
|
+
new URL('/', env.SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI).toString(),
|
|
194
|
+
)
|
|
195
|
+
return url.toString()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* ─── Signed session cookie helpers (HS256) ─────────────────────────────── */
|
|
199
|
+
|
|
200
|
+
let cachedHmacKey: CryptoKey | null = null
|
|
201
|
+
|
|
202
|
+
async function getHmacKey(): Promise<CryptoKey> {
|
|
203
|
+
if (cachedHmacKey) return cachedHmacKey
|
|
204
|
+
const env = getCustomerEnv()
|
|
205
|
+
cachedHmacKey = await crypto.subtle.importKey(
|
|
206
|
+
'raw',
|
|
207
|
+
new TextEncoder().encode(env.SHOPIFY_SESSION_SECRET),
|
|
208
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
209
|
+
false,
|
|
210
|
+
['sign', 'verify'],
|
|
211
|
+
)
|
|
212
|
+
return cachedHmacKey
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export type CustomerSession = {
|
|
216
|
+
accessToken: string
|
|
217
|
+
refreshToken: string
|
|
218
|
+
idToken: string
|
|
219
|
+
expiresAt: number
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function signSession(session: CustomerSession): Promise<string> {
|
|
223
|
+
const key = await getHmacKey()
|
|
224
|
+
const payload = base64UrlEncode(JSON.stringify(session))
|
|
225
|
+
const sig = await crypto.subtle.sign(
|
|
226
|
+
'HMAC',
|
|
227
|
+
key,
|
|
228
|
+
new TextEncoder().encode(payload),
|
|
229
|
+
)
|
|
230
|
+
return `${payload}.${bufferToBase64Url(sig)}`
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function verifySession(
|
|
234
|
+
signed: string,
|
|
235
|
+
): Promise<CustomerSession | null> {
|
|
236
|
+
const [payload, sig] = signed.split('.')
|
|
237
|
+
if (!payload || !sig) return null
|
|
238
|
+
const key = await getHmacKey()
|
|
239
|
+
const sigBytes = Uint8Array.from(base64UrlDecode(sig), (c) => c.charCodeAt(0))
|
|
240
|
+
const ok = await crypto.subtle.verify(
|
|
241
|
+
'HMAC',
|
|
242
|
+
key,
|
|
243
|
+
sigBytes,
|
|
244
|
+
new TextEncoder().encode(payload),
|
|
245
|
+
)
|
|
246
|
+
if (!ok) return null
|
|
247
|
+
try {
|
|
248
|
+
return JSON.parse(base64UrlDecode(payload)) as CustomerSession
|
|
249
|
+
} catch {
|
|
250
|
+
return null
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function buildSession(token: TokenResponse): CustomerSession {
|
|
255
|
+
return {
|
|
256
|
+
accessToken: token.access_token,
|
|
257
|
+
refreshToken: token.refresh_token,
|
|
258
|
+
idToken: token.id_token,
|
|
259
|
+
expiresAt: Date.now() + token.expires_in * 1000,
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export type OAuthFlightState = {
|
|
264
|
+
state: string
|
|
265
|
+
codeVerifier: string
|
|
266
|
+
redirectAfter: string
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function signFlightState(
|
|
270
|
+
state: OAuthFlightState,
|
|
271
|
+
): Promise<string> {
|
|
272
|
+
const key = await getHmacKey()
|
|
273
|
+
const payload = base64UrlEncode(JSON.stringify(state))
|
|
274
|
+
const sig = await crypto.subtle.sign(
|
|
275
|
+
'HMAC',
|
|
276
|
+
key,
|
|
277
|
+
new TextEncoder().encode(payload),
|
|
278
|
+
)
|
|
279
|
+
return `${payload}.${bufferToBase64Url(sig)}`
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function verifyFlightState(
|
|
283
|
+
signed: string,
|
|
284
|
+
): Promise<OAuthFlightState | null> {
|
|
285
|
+
const [payload, sig] = signed.split('.')
|
|
286
|
+
if (!payload || !sig) return null
|
|
287
|
+
const key = await getHmacKey()
|
|
288
|
+
const sigBytes = Uint8Array.from(base64UrlDecode(sig), (c) => c.charCodeAt(0))
|
|
289
|
+
const ok = await crypto.subtle.verify(
|
|
290
|
+
'HMAC',
|
|
291
|
+
key,
|
|
292
|
+
sigBytes,
|
|
293
|
+
new TextEncoder().encode(payload),
|
|
294
|
+
)
|
|
295
|
+
if (!ok) return null
|
|
296
|
+
try {
|
|
297
|
+
return JSON.parse(base64UrlDecode(payload)) as OAuthFlightState
|
|
298
|
+
} catch {
|
|
299
|
+
return null
|
|
300
|
+
}
|
|
301
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { getStorefrontEnv } from './env'
|
|
2
|
+
|
|
3
|
+
type ShopifyFetchInput<TVariables> = {
|
|
4
|
+
query: string
|
|
5
|
+
variables?: TVariables
|
|
6
|
+
/**
|
|
7
|
+
* Optional buyer IP, forwarded to Shopify's bot-protection headers.
|
|
8
|
+
* Only meaningful with the private token.
|
|
9
|
+
*/
|
|
10
|
+
buyerIp?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type ShopifyResponse<TData> = {
|
|
14
|
+
data?: TData
|
|
15
|
+
errors?: Array<{ message: string; locations?: unknown; path?: unknown }>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ShopifyError extends Error {
|
|
19
|
+
constructor(
|
|
20
|
+
message: string,
|
|
21
|
+
public readonly errors?: ShopifyResponse<unknown>['errors'],
|
|
22
|
+
) {
|
|
23
|
+
super(message)
|
|
24
|
+
this.name = 'ShopifyError'
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read the buyer's IP from a Request's headers in a runtime-portable way.
|
|
30
|
+
* Cloudflare uses `cf-connecting-ip`; Vercel/Netlify/Node behind a proxy use
|
|
31
|
+
* `x-forwarded-for`. Returns undefined if neither header is present.
|
|
32
|
+
*/
|
|
33
|
+
export function getBuyerIp(headers: Headers): string | undefined {
|
|
34
|
+
const cf = headers.get('cf-connecting-ip')
|
|
35
|
+
if (cf) return cf
|
|
36
|
+
const fwd = headers.get('x-forwarded-for')
|
|
37
|
+
if (fwd) return fwd.split(',')[0]?.trim()
|
|
38
|
+
return undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Server-side Storefront API client. Prefers the private access token (higher
|
|
43
|
+
* rate limits + buyer-IP forwarding); falls back to the public token so the
|
|
44
|
+
* default demo store works with zero setup.
|
|
45
|
+
*
|
|
46
|
+
* Use in route loaders and server functions only — never in browser code.
|
|
47
|
+
*/
|
|
48
|
+
export async function shopifyServerFetch<
|
|
49
|
+
TData,
|
|
50
|
+
TVariables = Record<string, unknown>,
|
|
51
|
+
>(input: ShopifyFetchInput<TVariables>): Promise<TData> {
|
|
52
|
+
const env = getStorefrontEnv()
|
|
53
|
+
const usingPrivate = Boolean(env.SHOPIFY_PRIVATE_STOREFRONT_TOKEN)
|
|
54
|
+
const token =
|
|
55
|
+
env.SHOPIFY_PRIVATE_STOREFRONT_TOKEN ?? env.SHOPIFY_PUBLIC_STOREFRONT_TOKEN
|
|
56
|
+
|
|
57
|
+
if (!token) {
|
|
58
|
+
throw new ShopifyError(
|
|
59
|
+
'Shopify Storefront token missing. Set SHOPIFY_PUBLIC_STOREFRONT_TOKEN in .env.local.',
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const headers: Record<string, string> = {
|
|
64
|
+
'Content-Type': 'application/json',
|
|
65
|
+
Accept: 'application/json',
|
|
66
|
+
}
|
|
67
|
+
if (usingPrivate) {
|
|
68
|
+
headers['Shopify-Storefront-Private-Token'] = token
|
|
69
|
+
if (input.buyerIp) headers['Shopify-Storefront-Buyer-IP'] = input.buyerIp
|
|
70
|
+
} else {
|
|
71
|
+
headers['X-Shopify-Storefront-Access-Token'] = token
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const url = `https://${env.SHOPIFY_STORE_DOMAIN}/api/${env.SHOPIFY_STOREFRONT_API_VERSION}/graphql.json`
|
|
75
|
+
const response = await fetch(url, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers,
|
|
78
|
+
body: JSON.stringify({ query: input.query, variables: input.variables }),
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
throw new ShopifyError(
|
|
83
|
+
`Shopify API error: ${response.status} ${response.statusText}`,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const json = (await response.json()) as ShopifyResponse<TData>
|
|
88
|
+
|
|
89
|
+
if (json.errors?.length) {
|
|
90
|
+
throw new ShopifyError(
|
|
91
|
+
json.errors.map((e) => e.message).join('\n'),
|
|
92
|
+
json.errors,
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!json.data) {
|
|
97
|
+
throw new ShopifyError('Shopify API returned no data and no errors.')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return json.data
|
|
101
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Shopify",
|
|
3
|
+
"description": "Headless Shopify storefront — products, cart, Shopify-hosted checkout, and optional customer accounts. Mounts /shop/* routes alongside your existing app.",
|
|
4
|
+
"phase": "add-on",
|
|
5
|
+
"modes": ["file-router"],
|
|
6
|
+
"type": "add-on",
|
|
7
|
+
"category": "other",
|
|
8
|
+
"color": "#5A31F4",
|
|
9
|
+
"priority": 80,
|
|
10
|
+
"link": "https://shopify.dev/docs/storefronts/headless",
|
|
11
|
+
"dependsOn": ["tanstack-query"],
|
|
12
|
+
"options": {
|
|
13
|
+
"customerAccount": {
|
|
14
|
+
"type": "select",
|
|
15
|
+
"label": "Customer Accounts",
|
|
16
|
+
"description": "Add /shop/account/* routes (Shopify Customer Account API, OAuth setup required).",
|
|
17
|
+
"default": "disabled",
|
|
18
|
+
"options": [
|
|
19
|
+
{ "value": "disabled", "label": "No (skip account routes)" },
|
|
20
|
+
{ "value": "enabled", "label": "Yes (include OAuth + account dashboard)" }
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"integrations": [
|
|
25
|
+
{
|
|
26
|
+
"type": "header-user",
|
|
27
|
+
"jsName": "ShopifyHeaderCart",
|
|
28
|
+
"path": "src/integrations/shopify/header-cart.tsx"
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"routes": [
|
|
32
|
+
{
|
|
33
|
+
"icon": "ShoppingBag",
|
|
34
|
+
"url": "/shop",
|
|
35
|
+
"name": "Shop",
|
|
36
|
+
"path": "src/routes/shop.index.tsx",
|
|
37
|
+
"jsName": "ShopIndex"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"url": "/shop/cart",
|
|
41
|
+
"name": "Cart",
|
|
42
|
+
"path": "src/routes/shop.cart.tsx",
|
|
43
|
+
"jsName": "ShopCart"
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
"envVars": [
|
|
47
|
+
{
|
|
48
|
+
"name": "SHOPIFY_STORE_DOMAIN",
|
|
49
|
+
"description": "Your store's myshopify.com domain. Defaults to Shopify's public Hydrogen demo store.",
|
|
50
|
+
"required": false,
|
|
51
|
+
"secret": false,
|
|
52
|
+
"file": ".env.local"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"name": "SHOPIFY_STOREFRONT_API_VERSION",
|
|
56
|
+
"description": "Storefront API version (e.g. 2026-01).",
|
|
57
|
+
"required": false,
|
|
58
|
+
"secret": false,
|
|
59
|
+
"file": ".env.local"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"name": "SHOPIFY_PUBLIC_STOREFRONT_TOKEN",
|
|
63
|
+
"description": "Public Storefront API token. Defaults to Shopify's public Hydrogen demo token.",
|
|
64
|
+
"required": false,
|
|
65
|
+
"secret": false,
|
|
66
|
+
"file": ".env.local"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"name": "SHOPIFY_PRIVATE_STOREFRONT_TOKEN",
|
|
70
|
+
"description": "Optional private Storefront API token. Higher rate limits + buyer-IP forwarding when set.",
|
|
71
|
+
"required": false,
|
|
72
|
+
"secret": true,
|
|
73
|
+
"file": ".env.local"
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"name": "SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID",
|
|
77
|
+
"description": "Customer Account API client ID. Required when customerAccount=enabled.",
|
|
78
|
+
"required": false,
|
|
79
|
+
"secret": true,
|
|
80
|
+
"file": ".env.local"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"name": "SHOPIFY_CUSTOMER_ACCOUNT_SHOP_ID",
|
|
84
|
+
"description": "Numeric Shop ID (Shopify admin > Settings > Customer accounts). Required when customerAccount=enabled.",
|
|
85
|
+
"required": false,
|
|
86
|
+
"secret": false,
|
|
87
|
+
"file": ".env.local"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"name": "SHOPIFY_CUSTOMER_ACCOUNT_REDIRECT_URI",
|
|
91
|
+
"description": "OAuth callback URL. Defaults to http://localhost:3000/shop/account/callback.",
|
|
92
|
+
"required": false,
|
|
93
|
+
"secret": false,
|
|
94
|
+
"file": ".env.local"
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"name": "SHOPIFY_SESSION_SECRET",
|
|
98
|
+
"description": "32+ char secret used to sign customer session cookies. Required when customerAccount=enabled.",
|
|
99
|
+
"required": false,
|
|
100
|
+
"secret": true,
|
|
101
|
+
"file": ".env.local"
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 109 124" width="32" height="36"><path fill="#95BF47" d="M74.7 14.8c0-.4-.4-.6-.7-.7-.3-.1-6.2-.5-6.2-.5s-4.1-4.1-4.6-4.6c-.5-.5-1.4-.3-1.7-.2 0 0-.9.3-2.4.7-.2-.8-.6-1.7-1.1-2.7-1.6-3.1-4-4.7-6.9-4.8-.2 0-.4 0-.6.1-.1-.1-.2-.2-.2-.3C49.1.7 47.5.1 45.6.1c-3.7.1-7.5 2.8-10.5 7.6-2.1 3.4-3.7 7.6-4.2 10.9-4.3 1.3-7.3 2.3-7.4 2.3-2.2.7-2.2.7-2.5 2.8-.2 1.5-5.8 45.4-5.8 45.4l47.3 8.2 20.5-5.1c0-.1-8.3-56-8.3-56.4zm-15-3.7c-1.1.3-2.4.7-3.7 1.2 0-1.9-.3-4.5-1.1-6.7 2.7.4 4 3.5 4.8 5.5zm-6.2 1.9c-2.5.8-5.3 1.6-8.1 2.5.8-3 2.3-6 4.1-8 .7-.7 1.7-1.6 2.8-2.1 1.2 2.4 1.4 5.7 1.2 7.6zm-5.1-9.7c.9 0 1.7.2 2.4.6-1.1.6-2.1 1.4-3.1 2.4-2.4 2.6-4.3 6.7-5 10.6-2.4.7-4.7 1.5-6.8 2.1 1.4-6.4 6.6-15.5 12.5-15.7z"/><path fill="#5E8E3E" d="M74 14.1c-.3-.1-6.2-.5-6.2-.5s-4.1-4.1-4.6-4.6c-.2-.2-.4-.3-.7-.3v75l20.5-5.1S74.4 22.7 74.4 22.3c0-.4-.4-.6-.4-.6.1.1-.4-7.6 0-7.6z"/><path fill="#FFF" d="M51.4 25.7l-2.4 8.9s-2.7-1.2-5.8-1c-4.6.3-4.6 3.2-4.6 3.9.2 3.9 10.6 4.8 11.2 14.1.5 7.3-3.9 12.3-10.1 12.7-7.5.5-11.6-3.9-11.6-3.9l1.6-6.8s4.2 3.1 7.5 2.9c2.2-.1 2.9-1.9 2.9-3.1-.3-5.1-8.7-4.8-9.3-13.3-.4-7.2 4.3-14.4 14.6-15.1 4-.3 6 .7 6 .7z"/></svg>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Shopify Storefront
|
|
2
|
+
|
|
3
|
+
A storefront-first TanStack Start template. Bundles the [Shopify add-on](../../add-ons/shopify)
|
|
4
|
+
plus a polished home page so the home route (`/`) is your shop landing.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npx @tanstack/cli create my-shop --template shopify-storefront
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## What you get
|
|
11
|
+
|
|
12
|
+
- `/` — Storefront landing (replaces the default home)
|
|
13
|
+
- `/shop` — Catalog landing with collections sidebar
|
|
14
|
+
- `/shop/products/$handle`, `/shop/collections/$handle`, `/shop/cart`, `/shop/search`,
|
|
15
|
+
`/shop/pages/$handle`, `/shop/policies/$handle` — full Hydrogen-demo parity
|
|
16
|
+
- `/shop/account/*` — optional, if you opted into customer accounts during scaffold
|
|
17
|
+
|
|
18
|
+
The default `.env.local` points at Shopify's public Hydrogen demo store, so you'll
|
|
19
|
+
see real products on first run with zero setup. See
|
|
20
|
+
[the add-on README](../../add-ons/shopify/README.md) for connecting your own store.
|
|
21
|
+
|
|
22
|
+
## Brand swap-out
|
|
23
|
+
|
|
24
|
+
Three files own the look-and-feel:
|
|
25
|
+
|
|
26
|
+
- `src/routes/index.tsx` — the home page hero + featured collections
|
|
27
|
+
- `src/components/ShopHero.tsx` — the marquee
|
|
28
|
+
- `src/components/FeaturedCollections.tsx` — the collection cards
|
|
29
|
+
|
|
30
|
+
The shop's design tokens (`--storefront-bg`, `--storefront-fg`, `--storefront-accent`,
|
|
31
|
+
etc.) are defined in the Shopify add-on's `src/components/shop/shop.css`. Override
|
|
32
|
+
those six variables in your own CSS to re-skin the entire storefront.
|
|
33
|
+
|
|
34
|
+
## Removing demo content
|
|
35
|
+
|
|
36
|
+
To strip the hero/landing and use the bare add-on instead:
|
|
37
|
+
|
|
38
|
+
1. Delete `src/components/ShopHero.tsx` and `src/components/FeaturedCollections.tsx`.
|
|
39
|
+
2. Replace `src/routes/index.tsx` with a redirect to `/shop` (or your preferred home).
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Link } from '@tanstack/react-router'
|
|
2
|
+
|
|
3
|
+
import { ShopImage } from '#/components/shop/shop-image'
|
|
4
|
+
import type { CollectionListItem } from '#/lib/shopify/queries'
|
|
5
|
+
|
|
6
|
+
type FeaturedCollectionsProps = {
|
|
7
|
+
collections: ReadonlyArray<CollectionListItem>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function FeaturedCollections({ collections }: FeaturedCollectionsProps) {
|
|
11
|
+
if (collections.length === 0) return null
|
|
12
|
+
return (
|
|
13
|
+
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
14
|
+
{collections.map((collection) => (
|
|
15
|
+
<Link
|
|
16
|
+
key={collection.id}
|
|
17
|
+
to="/shop/collections/$handle"
|
|
18
|
+
params={{ handle: collection.handle }}
|
|
19
|
+
className="group relative block overflow-hidden rounded-2xl bg-[var(--storefront-line)] no-underline"
|
|
20
|
+
style={{ aspectRatio: '4 / 5' }}
|
|
21
|
+
>
|
|
22
|
+
{collection.image && (
|
|
23
|
+
<ShopImage
|
|
24
|
+
src={collection.image.url}
|
|
25
|
+
alt={collection.image.altText ?? collection.title}
|
|
26
|
+
width={800}
|
|
27
|
+
height={1000}
|
|
28
|
+
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
|
29
|
+
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
|
|
30
|
+
/>
|
|
31
|
+
)}
|
|
32
|
+
<div className="absolute inset-0 bg-gradient-to-t from-black/55 via-black/0 to-black/0" />
|
|
33
|
+
<div className="absolute inset-x-0 bottom-0 p-6 text-white">
|
|
34
|
+
<h3 className="text-2xl font-medium tracking-tight">
|
|
35
|
+
{collection.title}
|
|
36
|
+
</h3>
|
|
37
|
+
<p className="mt-1 text-sm text-white/80">Shop the collection →</p>
|
|
38
|
+
</div>
|
|
39
|
+
</Link>
|
|
40
|
+
))}
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Link } from '@tanstack/react-router'
|
|
2
|
+
|
|
3
|
+
type ShopHeroProps = {
|
|
4
|
+
title: string
|
|
5
|
+
tagline: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ShopHero({ title, tagline }: ShopHeroProps) {
|
|
9
|
+
return (
|
|
10
|
+
<section className="relative isolate overflow-hidden border-b border-[var(--storefront-line)]">
|
|
11
|
+
<div className="mx-auto flex max-w-7xl flex-col items-start gap-8 px-4 py-24 sm:px-6 sm:py-32 lg:px-8 lg:py-40">
|
|
12
|
+
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--storefront-fg-muted)]">
|
|
13
|
+
New season · 2026
|
|
14
|
+
</p>
|
|
15
|
+
<h1 className="max-w-3xl text-5xl font-medium tracking-tight sm:text-6xl lg:text-7xl">
|
|
16
|
+
{title}
|
|
17
|
+
</h1>
|
|
18
|
+
<p className="max-w-xl text-lg text-[var(--storefront-fg-muted)]">
|
|
19
|
+
{tagline}
|
|
20
|
+
</p>
|
|
21
|
+
<div className="mt-2 flex flex-wrap gap-3">
|
|
22
|
+
<Link
|
|
23
|
+
to="/shop"
|
|
24
|
+
className="rounded-full bg-[var(--storefront-accent)] px-6 py-3 text-sm font-medium text-[var(--storefront-accent-fg)] no-underline transition hover:opacity-90"
|
|
25
|
+
>
|
|
26
|
+
Shop all
|
|
27
|
+
</Link>
|
|
28
|
+
<Link
|
|
29
|
+
to="/shop/search"
|
|
30
|
+
search={{ q: '' }}
|
|
31
|
+
className="rounded-full border border-[var(--storefront-fg)] px-6 py-3 text-sm font-medium no-underline text-[var(--storefront-fg)] transition hover:bg-[var(--storefront-fg)] hover:text-[var(--storefront-bg)]"
|
|
32
|
+
>
|
|
33
|
+
Search
|
|
34
|
+
</Link>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</section>
|
|
38
|
+
)
|
|
39
|
+
}
|