@shopbb/helium 0.5.10 → 0.6.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 (132) hide show
  1. package/dist/cache/withCache.d.ts +49 -0
  2. package/dist/cache/withCache.d.ts.map +1 -0
  3. package/dist/cache/withCache.js +117 -0
  4. package/dist/cache/withCache.js.map +1 -0
  5. package/dist/components/AddToCartButton.d.ts +28 -22
  6. package/dist/components/AddToCartButton.d.ts.map +1 -1
  7. package/dist/components/AddToCartButton.js +36 -47
  8. package/dist/components/AddToCartButton.js.map +1 -1
  9. package/dist/components/BuyNowButton.d.ts +45 -0
  10. package/dist/components/BuyNowButton.d.ts.map +1 -0
  11. package/dist/components/BuyNowButton.js +49 -0
  12. package/dist/components/BuyNowButton.js.map +1 -0
  13. package/dist/components/CartCheckoutButton.d.ts +39 -0
  14. package/dist/components/CartCheckoutButton.d.ts.map +1 -0
  15. package/dist/components/CartCheckoutButton.js +32 -0
  16. package/dist/components/CartCheckoutButton.js.map +1 -0
  17. package/dist/components/CartCost.d.ts +43 -0
  18. package/dist/components/CartCost.d.ts.map +1 -0
  19. package/dist/components/CartCost.js +34 -0
  20. package/dist/components/CartCost.js.map +1 -0
  21. package/dist/components/CartForm.d.ts +201 -0
  22. package/dist/components/CartForm.d.ts.map +1 -0
  23. package/dist/components/CartForm.js +213 -0
  24. package/dist/components/CartForm.js.map +1 -0
  25. package/dist/components/CartLineProvider.d.ts +78 -0
  26. package/dist/components/CartLineProvider.d.ts.map +1 -0
  27. package/dist/components/CartLineProvider.js +46 -0
  28. package/dist/components/CartLineProvider.js.map +1 -0
  29. package/dist/components/CartLineQuantity.d.ts +24 -0
  30. package/dist/components/CartLineQuantity.d.ts.map +1 -0
  31. package/dist/components/CartLineQuantity.js +9 -0
  32. package/dist/components/CartLineQuantity.js.map +1 -0
  33. package/dist/components/DiscountSelector.d.ts.map +1 -1
  34. package/dist/components/DiscountSelector.js +8 -19
  35. package/dist/components/DiscountSelector.js.map +1 -1
  36. package/dist/components/Image.d.ts +18 -0
  37. package/dist/components/Image.d.ts.map +1 -1
  38. package/dist/components/Image.js +26 -0
  39. package/dist/components/Image.js.map +1 -1
  40. package/dist/components/Pagination.d.ts +82 -0
  41. package/dist/components/Pagination.d.ts.map +1 -0
  42. package/dist/components/Pagination.js +84 -0
  43. package/dist/components/Pagination.js.map +1 -0
  44. package/dist/components/RichText.d.ts +78 -0
  45. package/dist/components/RichText.d.ts.map +1 -0
  46. package/dist/components/RichText.js +93 -0
  47. package/dist/components/RichText.js.map +1 -0
  48. package/dist/components/Seo.d.ts +25 -0
  49. package/dist/components/Seo.d.ts.map +1 -0
  50. package/dist/components/Seo.js +54 -0
  51. package/dist/components/Seo.js.map +1 -0
  52. package/dist/components/hooks/useMoney.d.ts +40 -0
  53. package/dist/components/hooks/useMoney.d.ts.map +1 -0
  54. package/dist/components/hooks/useMoney.js +60 -0
  55. package/dist/components/hooks/useMoney.js.map +1 -0
  56. package/dist/components/hooks/useOptimisticCart.d.ts +50 -0
  57. package/dist/components/hooks/useOptimisticCart.d.ts.map +1 -0
  58. package/dist/components/hooks/useOptimisticCart.js +138 -0
  59. package/dist/components/hooks/useOptimisticCart.js.map +1 -0
  60. package/dist/components/index.d.ts +28 -0
  61. package/dist/components/index.d.ts.map +1 -1
  62. package/dist/components/index.js +21 -0
  63. package/dist/components/index.js.map +1 -1
  64. package/dist/createCartHandler.d.ts.map +1 -1
  65. package/dist/createCartHandler.js +57 -0
  66. package/dist/createCartHandler.js.map +1 -1
  67. package/dist/csp/csp.d.ts +57 -0
  68. package/dist/csp/csp.d.ts.map +1 -0
  69. package/dist/csp/csp.js +73 -0
  70. package/dist/csp/csp.js.map +1 -0
  71. package/dist/customer/createCustomerAccountClient.d.ts +43 -0
  72. package/dist/customer/createCustomerAccountClient.d.ts.map +1 -0
  73. package/dist/customer/createCustomerAccountClient.js +68 -0
  74. package/dist/customer/createCustomerAccountClient.js.map +1 -0
  75. package/dist/handleCartFormAction.d.ts +39 -0
  76. package/dist/handleCartFormAction.d.ts.map +1 -0
  77. package/dist/handleCartFormAction.js +103 -0
  78. package/dist/handleCartFormAction.js.map +1 -0
  79. package/dist/index.d.ts +18 -0
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +11 -0
  82. package/dist/index.js.map +1 -1
  83. package/dist/routing/storefrontRedirect.d.ts +37 -0
  84. package/dist/routing/storefrontRedirect.d.ts.map +1 -0
  85. package/dist/routing/storefrontRedirect.js +64 -0
  86. package/dist/routing/storefrontRedirect.js.map +1 -0
  87. package/dist/seo/getSeoMeta.d.ts +68 -0
  88. package/dist/seo/getSeoMeta.d.ts.map +1 -0
  89. package/dist/seo/getSeoMeta.js +89 -0
  90. package/dist/seo/getSeoMeta.js.map +1 -0
  91. package/dist/sitemap/sitemap.d.ts +55 -0
  92. package/dist/sitemap/sitemap.d.ts.map +1 -0
  93. package/dist/sitemap/sitemap.js +93 -0
  94. package/dist/sitemap/sitemap.js.map +1 -0
  95. package/dist/types.d.ts +12 -0
  96. package/dist/types.d.ts.map +1 -1
  97. package/dist/utils/flattenConnection.d.ts +25 -0
  98. package/dist/utils/flattenConnection.d.ts.map +1 -0
  99. package/dist/utils/flattenConnection.js +25 -0
  100. package/dist/utils/flattenConnection.js.map +1 -0
  101. package/dist/utils/parseGid.d.ts +17 -0
  102. package/dist/utils/parseGid.d.ts.map +1 -0
  103. package/dist/utils/parseGid.js +19 -0
  104. package/dist/utils/parseGid.js.map +1 -0
  105. package/package.json +1 -1
  106. package/src/cache/withCache.ts +144 -0
  107. package/src/components/AddToCartButton.tsx +94 -56
  108. package/src/components/BuyNowButton.tsx +135 -0
  109. package/src/components/CartCheckoutButton.tsx +97 -0
  110. package/src/components/CartCost.tsx +65 -0
  111. package/src/components/CartForm.tsx +311 -0
  112. package/src/components/CartLineProvider.tsx +77 -0
  113. package/src/components/CartLineQuantity.tsx +37 -0
  114. package/src/components/DiscountSelector.tsx +34 -45
  115. package/src/components/Image.tsx +27 -0
  116. package/src/components/Pagination.tsx +139 -0
  117. package/src/components/RichText.tsx +122 -0
  118. package/src/components/Seo.tsx +61 -0
  119. package/src/components/hooks/useMoney.ts +87 -0
  120. package/src/components/hooks/useOptimisticCart.ts +173 -0
  121. package/src/components/index.ts +44 -0
  122. package/src/createCartHandler.ts +71 -0
  123. package/src/csp/csp.tsx +119 -0
  124. package/src/customer/createCustomerAccountClient.ts +89 -0
  125. package/src/handleCartFormAction.ts +129 -0
  126. package/src/index.ts +24 -0
  127. package/src/routing/storefrontRedirect.ts +86 -0
  128. package/src/seo/getSeoMeta.ts +125 -0
  129. package/src/sitemap/sitemap.ts +121 -0
  130. package/src/types.ts +12 -1
  131. package/src/utils/flattenConnection.ts +33 -0
  132. package/src/utils/parseGid.ts +25 -0
@@ -112,6 +112,36 @@ export function createCartHandler(options: CartHandlerOptions): CartHandler {
112
112
  }
113
113
  `;
114
114
 
115
+ const CART_DISCOUNT_CODES_UPDATE = /* GraphQL */ `
116
+ ${DEFAULT_CART_FRAGMENT}
117
+ mutation CartDiscountCodesUpdate($cartId: ID!, $discountCodes: [String!]) {
118
+ cartDiscountCodesUpdate(cartId: $cartId, discountCodes: $discountCodes) {
119
+ cart { ...CartReturn }
120
+ userErrors { field message code }
121
+ }
122
+ }
123
+ `;
124
+
125
+ const CART_DISCOUNT_SELECT = /* GraphQL */ `
126
+ ${DEFAULT_CART_FRAGMENT}
127
+ mutation CartDiscountSelect($cartId: ID!, $claimId: ID!) {
128
+ cartDiscountSelect(cartId: $cartId, claimId: $claimId) {
129
+ cart { ...CartReturn }
130
+ userErrors { field message code }
131
+ }
132
+ }
133
+ `;
134
+
135
+ const CART_DISCOUNT_CLEAR = /* GraphQL */ `
136
+ ${DEFAULT_CART_FRAGMENT}
137
+ mutation CartDiscountClear($cartId: ID!) {
138
+ cartDiscountClear(cartId: $cartId) {
139
+ cart { ...CartReturn }
140
+ userErrors { field message code }
141
+ }
142
+ }
143
+ `;
144
+
115
145
  // ----- Handler methods -----
116
146
 
117
147
  async function get(): Promise<any | null> {
@@ -177,6 +207,44 @@ export function createCartHandler(options: CartHandlerOptions): CartHandler {
177
207
  return { cart: data.cartLinesRemove.cart, userErrors: data.cartLinesRemove.userErrors };
178
208
  }
179
209
 
210
+ async function updateDiscountCodes(discountCodes: string[]): Promise<CartResult> {
211
+ const cartId = getCartId();
212
+ if (!cartId) {
213
+ // 没 cart 就先创建一个再应用 discount
214
+ const created = await create({});
215
+ if (!created.cart) return created;
216
+ }
217
+ const data = await storefront.mutate<{ cartDiscountCodesUpdate: { cart: any; userErrors: any[] } }>(
218
+ CART_DISCOUNT_CODES_UPDATE,
219
+ { variables: { cartId: getCartId(), discountCodes } },
220
+ );
221
+ return { cart: data.cartDiscountCodesUpdate.cart, userErrors: data.cartDiscountCodesUpdate.userErrors };
222
+ }
223
+
224
+ async function selectDiscount(claimId: string): Promise<CartResult> {
225
+ const cartId = getCartId();
226
+ if (!cartId) {
227
+ return { cart: null, userErrors: [{ message: 'No cart exists', code: 'NO_CART' }] };
228
+ }
229
+ const data = await storefront.mutate<{ cartDiscountSelect: { cart: any; userErrors: any[] } }>(
230
+ CART_DISCOUNT_SELECT,
231
+ { variables: { cartId, claimId } },
232
+ );
233
+ return { cart: data.cartDiscountSelect.cart, userErrors: data.cartDiscountSelect.userErrors };
234
+ }
235
+
236
+ async function clearDiscount(): Promise<CartResult> {
237
+ const cartId = getCartId();
238
+ if (!cartId) {
239
+ return { cart: null, userErrors: [{ message: 'No cart exists', code: 'NO_CART' }] };
240
+ }
241
+ const data = await storefront.mutate<{ cartDiscountClear: { cart: any; userErrors: any[] } }>(
242
+ CART_DISCOUNT_CLEAR,
243
+ { variables: { cartId } },
244
+ );
245
+ return { cart: data.cartDiscountClear.cart, userErrors: data.cartDiscountClear.userErrors };
246
+ }
247
+
180
248
  return {
181
249
  get,
182
250
  getCartId,
@@ -185,5 +253,8 @@ export function createCartHandler(options: CartHandlerOptions): CartHandler {
185
253
  addLines,
186
254
  updateLines,
187
255
  removeLines,
256
+ updateDiscountCodes,
257
+ selectDiscount,
258
+ clearDiscount,
188
259
  };
189
260
  }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * createContentSecurityPolicy + NonceProvider + <Script> + useNonce()
3
+ *
4
+ * 对齐 Hydrogen 的 CSP 工具集:服务端生成一次性 nonce(base64 random),通过
5
+ * Content-Security-Policy 头要求所有 inline script 带这个 nonce 才能执行;
6
+ * React 组件 <Script nonce> 自动注入 nonce 属性。
7
+ *
8
+ * 服务端:
9
+ * const { nonce, header, NonceProvider } = createContentSecurityPolicy();
10
+ * response.headers.set('Content-Security-Policy', header);
11
+ * return ReactDOMServer.renderToReadableStream(
12
+ * <NonceProvider value={nonce}><App /></NonceProvider>
13
+ * );
14
+ *
15
+ * 客户端 / 组件内:
16
+ * const nonce = useNonce();
17
+ * <script nonce={nonce} dangerouslySetInnerHTML={...} />
18
+ * // 或者用 <Script>
19
+ * <Script>{`window.foo=1`}</Script>
20
+ */
21
+
22
+ import * as React from 'react';
23
+
24
+ // ============================================================
25
+ // Nonce context
26
+ // ============================================================
27
+
28
+ const NonceContext = React.createContext<string | null>(null);
29
+
30
+ export interface NonceProviderProps {
31
+ value: string;
32
+ children: React.ReactNode;
33
+ }
34
+
35
+ export function NonceProvider({ value, children }: NonceProviderProps) {
36
+ return <NonceContext.Provider value={value}>{children}</NonceContext.Provider>;
37
+ }
38
+
39
+ export function useNonce(): string | undefined {
40
+ const nonce = React.useContext(NonceContext);
41
+ return nonce || undefined;
42
+ }
43
+
44
+ // ============================================================
45
+ // <Script> — 自动带 nonce 的 script tag
46
+ // ============================================================
47
+
48
+ export interface ScriptProps extends React.ScriptHTMLAttributes<HTMLScriptElement> {}
49
+
50
+ export function Script(props: ScriptProps) {
51
+ const nonce = useNonce();
52
+ return <script nonce={nonce} {...props} />;
53
+ }
54
+
55
+ // ============================================================
56
+ // createContentSecurityPolicy
57
+ // ============================================================
58
+
59
+ export interface CspOptions {
60
+ /** 额外的 script-src 来源(默认 'self') */
61
+ scriptSrc?: string[];
62
+ /** 额外的 style-src(默认 'self' + 'unsafe-inline') */
63
+ styleSrc?: string[];
64
+ /** 额外的 img-src(默认 'self' + data:) */
65
+ imgSrc?: string[];
66
+ /** connect-src(XHR / fetch 允许的目标) */
67
+ connectSrc?: string[];
68
+ /** font-src */
69
+ fontSrc?: string[];
70
+ /** frame-src */
71
+ frameSrc?: string[];
72
+ /** default-src */
73
+ defaultSrc?: string[];
74
+ /** Report-only 模式 */
75
+ reportOnly?: boolean;
76
+ }
77
+
78
+ export interface CspResult {
79
+ nonce: string;
80
+ /** 完整的 Content-Security-Policy header 字符串 */
81
+ header: string;
82
+ headerName: 'Content-Security-Policy' | 'Content-Security-Policy-Report-Only';
83
+ NonceProvider: typeof NonceProvider;
84
+ }
85
+
86
+ function randomNonce(): string {
87
+ // Workers / 浏览器 / Node 都有 crypto.getRandomValues
88
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
89
+ const bytes = new Uint8Array(16);
90
+ crypto.getRandomValues(bytes);
91
+ let bin = '';
92
+ for (const b of bytes) bin += String.fromCharCode(b);
93
+ return btoa(bin).replace(/[+/=]/g, '');
94
+ }
95
+ // fallback
96
+ return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
97
+ }
98
+
99
+ export function createContentSecurityPolicy(options: CspOptions = {}): CspResult {
100
+ const nonce = randomNonce();
101
+ const dirs: string[] = [];
102
+
103
+ dirs.push(`default-src ${(options.defaultSrc || ["'self'"]).join(' ')}`);
104
+ dirs.push(`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' ${(options.scriptSrc || []).join(' ')}`.trim());
105
+ dirs.push(`style-src 'self' 'unsafe-inline' ${(options.styleSrc || []).join(' ')}`.trim());
106
+ dirs.push(`img-src 'self' data: blob: ${(options.imgSrc || []).join(' ')}`.trim());
107
+ if (options.connectSrc?.length) dirs.push(`connect-src 'self' ${options.connectSrc.join(' ')}`);
108
+ if (options.fontSrc?.length) dirs.push(`font-src 'self' ${options.fontSrc.join(' ')}`);
109
+ if (options.frameSrc?.length) dirs.push(`frame-src ${options.frameSrc.join(' ')}`);
110
+ dirs.push(`base-uri 'self'`);
111
+ dirs.push(`form-action 'self'`);
112
+
113
+ return {
114
+ nonce,
115
+ header: dirs.join('; '),
116
+ headerName: options.reportOnly ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy',
117
+ NonceProvider,
118
+ };
119
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * createCustomerAccountClient — 对齐 Hydrogen createCustomerAccountClient
3
+ *
4
+ * 给"买家账号"操作提供 GraphQL client + 简单 token 管理。
5
+ *
6
+ * Hydrogen 用 Customer Account API(OAuth + PKCE);我们 shopbb 用简化的
7
+ * buyer JWT(service signed token),所以这里**不实现 OAuth login/logout**,
8
+ * 只提供 GraphQL query/mutate 和 isLoggedIn 检测。商家用 oxygen 提供的
9
+ * REST `/api/buyer/login` 完成登录,把 token 存进 localStorage。
10
+ *
11
+ * 用法(客户端):
12
+ *
13
+ * const customer = createCustomerAccountClient({
14
+ * customerApiUrl: 'https://api.../customer/api/2026-04/graphql',
15
+ * getAccessToken: () => localStorage.getItem('shopbb:buyer_token'),
16
+ * });
17
+ *
18
+ * const data = await customer.query(`{ customer { id email } }`);
19
+ * await customer.mutate(`mutation { customerAddressCreate(...) { ... } }`);
20
+ * const loggedIn = await customer.isLoggedIn();
21
+ *
22
+ * 与 Hydrogen 区别:
23
+ * - getAccessToken 默认从 localStorage / cookie 取(商家可覆盖)
24
+ * - 不带 OAuth redirect helper(commerce 简化)
25
+ * - login / logout 由商家应用层负责(调 oxygen REST)
26
+ */
27
+
28
+ export interface CustomerAccountClientOptions {
29
+ /** Customer GraphQL endpoint */
30
+ customerApiUrl: string;
31
+ /** Token 取法。不传时从 localStorage 取 'shopbb:buyer_token'。SSR 阶段可传 cookie 解析 */
32
+ getAccessToken?: () => string | null | Promise<string | null>;
33
+ /** 自定义 headers(如 store_id) */
34
+ extraHeaders?: Record<string, string>;
35
+ }
36
+
37
+ export interface CustomerAccountClient {
38
+ query<T = any>(query: string, variables?: Record<string, any>): Promise<T>;
39
+ mutate<T = any>(mutation: string, variables?: Record<string, any>): Promise<T>;
40
+ isLoggedIn(): Promise<boolean>;
41
+ getAccessToken(): Promise<string | null>;
42
+ getApiUrl(): string;
43
+ }
44
+
45
+ export function createCustomerAccountClient(options: CustomerAccountClientOptions): CustomerAccountClient {
46
+ const { customerApiUrl, getAccessToken: getter, extraHeaders } = options;
47
+
48
+ async function getAccessToken(): Promise<string | null> {
49
+ if (getter) return await getter();
50
+ if (typeof localStorage === 'undefined') return null;
51
+ return localStorage.getItem('shopbb:buyer_token') || localStorage.getItem('shopflare:buyer_token');
52
+ }
53
+
54
+ async function call<T = any>(
55
+ body: string,
56
+ variables?: Record<string, any>,
57
+ ): Promise<T> {
58
+ const token = await getAccessToken();
59
+ if (!token) throw new Error('not authenticated');
60
+ const headers: Record<string, string> = {
61
+ 'Content-Type': 'application/json',
62
+ 'Authorization': `Bearer ${token}`,
63
+ ...(extraHeaders || {}),
64
+ };
65
+ const res = await fetch(customerApiUrl, {
66
+ method: 'POST',
67
+ headers,
68
+ body: JSON.stringify({ query: body, variables }),
69
+ });
70
+ const json: any = await res.json().catch(() => null);
71
+ if (!res.ok || json?.errors?.length) {
72
+ throw new Error(json?.errors?.[0]?.message || `Customer API error: HTTP ${res.status}`);
73
+ }
74
+ return json.data as T;
75
+ }
76
+
77
+ async function isLoggedIn(): Promise<boolean> {
78
+ const token = await getAccessToken();
79
+ return !!token;
80
+ }
81
+
82
+ return {
83
+ query: (q, v) => call(q, v),
84
+ mutate: (m, v) => call(m, v),
85
+ isLoggedIn,
86
+ getAccessToken,
87
+ getApiUrl: () => customerApiUrl,
88
+ };
89
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * handleCartFormAction
3
+ *
4
+ * 服务端统一处理 <CartForm> 提交的 helper。对齐 Hydrogen 的"单一 /cart route action"模式:
5
+ *
6
+ * import { CartForm, handleCartFormAction } from '@shopbb/helium';
7
+ *
8
+ * // shopflare server.tsx POST handler
9
+ * if (url.pathname === '/cart' && request.method === 'POST') {
10
+ * const ctx = createHeliumContext({ ... });
11
+ * const response = await handleCartFormAction(request, ctx.cart, {
12
+ * responseHeaders: ctx.responseHeaders,
13
+ * });
14
+ * return response;
15
+ * }
16
+ *
17
+ * 行为:
18
+ * 1. 从 request.formData() 调 CartForm.getFormInput 解析出 { action, inputs }
19
+ * 2. switch(action) 分发到 cartHandler 对应方法
20
+ * 3. 返回 JSON Response,里面带 { cart, userErrors, errors? } 和 Set-Cookie header
21
+ * 4. 客户端 fetch 拿到这个 Response,调 CartProvider.applyCart 同步 state
22
+ *
23
+ * 也支持 progressive enhancement:JS 没加载时浏览器原生 form POST + 303 redirect 回 referrer。
24
+ * 通过 query param `?_progressive=1` 或检测 `Accept: text/html` 触发 303 模式。
25
+ */
26
+
27
+ import { CartForm, type CartFormInput } from './components/CartForm';
28
+ import type { CartHandler, CartResult } from './types';
29
+
30
+ export interface HandleCartFormActionOptions {
31
+ /** 服务端构造的 responseHeaders(Set-Cookie 写在这里) */
32
+ responseHeaders?: Headers;
33
+ /** JS 未加载时浏览器原生 form POST → 303 redirect 到这个 URL(默认 Referer 或 /cart) */
34
+ redirectTo?: string;
35
+ /**
36
+ * 自定义 action 处理器(CartForm 支持 `Custom${string}` action)。
37
+ * 第二个参数是 cartHandler,可以组合调用。
38
+ */
39
+ customActions?: Record<string, (inputs: any, cart: CartHandler) => Promise<CartResult>>;
40
+ }
41
+
42
+ export async function handleCartFormAction(
43
+ request: Request,
44
+ cart: CartHandler,
45
+ options: HandleCartFormActionOptions = {},
46
+ ): Promise<Response> {
47
+ const { responseHeaders, redirectTo, customActions } = options;
48
+
49
+ let formData: FormData;
50
+ try {
51
+ formData = await request.formData();
52
+ } catch (e: any) {
53
+ return jsonResponse({ error: 'Invalid form data: ' + (e.message || e), userErrors: [] }, 400, responseHeaders);
54
+ }
55
+
56
+ let parsed: CartFormInput;
57
+ try {
58
+ parsed = CartForm.getFormInput(formData);
59
+ } catch (e: any) {
60
+ return jsonResponse({ error: e.message || String(e), userErrors: [] }, 400, responseHeaders);
61
+ }
62
+
63
+ let result: CartResult;
64
+ try {
65
+ switch (parsed.action) {
66
+ case 'LinesAdd':
67
+ result = await cart.addLines((parsed.inputs as any).lines);
68
+ break;
69
+ case 'LinesUpdate':
70
+ result = await cart.updateLines((parsed.inputs as any).lines);
71
+ break;
72
+ case 'LinesRemove':
73
+ result = await cart.removeLines((parsed.inputs as any).lineIds);
74
+ break;
75
+ case 'DiscountCodesUpdate':
76
+ result = await cart.updateDiscountCodes((parsed.inputs as any).discountCodes || []);
77
+ break;
78
+ case 'Create':
79
+ result = await cart.create((parsed.inputs as any).input);
80
+ break;
81
+ default:
82
+ // shopbb-extension actions
83
+ if (parsed.action === 'CustomDiscountSelect') {
84
+ result = await cart.selectDiscount((parsed.inputs as any).claimId);
85
+ break;
86
+ }
87
+ if (parsed.action === 'CustomDiscountClear') {
88
+ result = await cart.clearDiscount();
89
+ break;
90
+ }
91
+ // 用户自定义
92
+ if (parsed.action.startsWith('Custom') && customActions && customActions[parsed.action]) {
93
+ result = await customActions[parsed.action](parsed.inputs, cart);
94
+ break;
95
+ }
96
+ return jsonResponse(
97
+ { error: `Unknown CartForm action: "${parsed.action}"`, userErrors: [] },
98
+ 400,
99
+ responseHeaders,
100
+ );
101
+ }
102
+ } catch (e: any) {
103
+ return jsonResponse({ error: e.message || String(e), userErrors: [] }, 500, responseHeaders);
104
+ }
105
+
106
+ // 写入 Set-Cookie(如果 cart id 变化)
107
+ if (result.cart?.id && responseHeaders) {
108
+ cart.setCartId(result.cart.id);
109
+ }
110
+
111
+ // 浏览器原生 form POST(无 JS)→ 303 redirect 回 referrer
112
+ // 判定:只信 X-Helium-Fetch header(CartForm fetch 显式带)
113
+ // 没有该 header → 视为原生 form POST,必须用 303 让浏览器跳转(不然显示 raw JSON)
114
+ const isJsFetch = !!request.headers.get('X-Helium-Fetch');
115
+ if (!isJsFetch) {
116
+ const target = redirectTo || request.headers.get('Referer') || '/cart';
117
+ const headers = new Headers(responseHeaders || {});
118
+ headers.set('Location', target);
119
+ return new Response(null, { status: 303, headers });
120
+ }
121
+
122
+ return jsonResponse({ cart: result.cart, userErrors: result.userErrors }, 200, responseHeaders);
123
+ }
124
+
125
+ function jsonResponse(body: any, status: number, responseHeaders?: Headers): Response {
126
+ const headers = new Headers(responseHeaders || {});
127
+ headers.set('Content-Type', 'application/json; charset=utf-8');
128
+ return new Response(JSON.stringify(body), { status, headers });
129
+ }
package/src/index.ts CHANGED
@@ -18,8 +18,32 @@ export { createStorefrontClient } from './createStorefrontClient';
18
18
  export { createCartHandler, DEFAULT_CART_FRAGMENT } from './createCartHandler';
19
19
  export { cartGetIdDefault, cartSetIdDefault } from './cart-id';
20
20
  export type { CartSetIdOptions } from './cart-id';
21
+ export { handleCartFormAction } from './handleCartFormAction';
22
+ export type { HandleCartFormActionOptions } from './handleCartFormAction';
21
23
  export { CacheNone, CacheShort, CacheLong, CacheCustom } from './cache';
22
24
 
25
+ // 服务端工具
26
+ export { createWithCache, InMemoryCache } from './cache/withCache';
27
+ export type { WithCache, WithCacheOptions, CacheKey } from './cache/withCache';
28
+ export { storefrontRedirect } from './routing/storefrontRedirect';
29
+ export type { StorefrontRedirectOptions } from './routing/storefrontRedirect';
30
+ export { getSeoMeta } from './seo/getSeoMeta';
31
+ export type { SeoConfig, SeoResult } from './seo/getSeoMeta';
32
+ export { createCustomerAccountClient } from './customer/createCustomerAccountClient';
33
+ export type { CustomerAccountClient, CustomerAccountClientOptions } from './customer/createCustomerAccountClient';
34
+ export { createContentSecurityPolicy, NonceProvider, useNonce, Script } from './csp/csp';
35
+ export type { CspOptions, CspResult, NonceProviderProps, ScriptProps } from './csp/csp';
36
+ export { getSitemap, getSitemapIndex } from './sitemap/sitemap';
37
+ export type {
38
+ SitemapResource, GetSitemapOptions, GetSitemapIndexOptions,
39
+ } from './sitemap/sitemap';
40
+
41
+ // 工具
42
+ export { flattenConnection } from './utils/flattenConnection';
43
+ export type { Connection } from './utils/flattenConnection';
44
+ export { parseGid } from './utils/parseGid';
45
+ export type { ParsedGid } from './utils/parseGid';
46
+
23
47
  // Re-export all types
24
48
  export type {
25
49
  CacheStrategy,
@@ -0,0 +1,86 @@
1
+ /**
2
+ * storefrontRedirect — 对齐 Hydrogen storefrontRedirect
3
+ *
4
+ * 商家从老平台搬过来时常有 URL 改写需求,Shopify 在 admin 里维护 redirects 表。
5
+ * 商家 storefront 收到 404 时,调用 `storefrontRedirect({request, storefront})`
6
+ * 查 oxygen 的 redirects 表,若有匹配 → 301 / 302 跳到新地址。
7
+ *
8
+ * 用法(在 404 catch handler 里):
9
+ *
10
+ * if (response.status === 404) {
11
+ * const redirect = await storefrontRedirect({ request, storefront });
12
+ * if (redirect) return redirect;
13
+ * }
14
+ * return notFound();
15
+ *
16
+ * 简化版当前实现:调用 oxygen REST `/api/redirects?path=...`,未来 oxygen 加这条
17
+ * endpoint。商家可以传 fetcher 自定义查询源。
18
+ */
19
+
20
+ export interface StorefrontRedirectOptions {
21
+ /** 当前 Request,从 URL 取 path 查 redirect */
22
+ request: Request;
23
+ /** oxygen Storefront client(用于 GraphQL urlRedirects 查询);或自定义 fetcher */
24
+ storefront?: { query: (q: string, opts?: any) => Promise<any> };
25
+ /**
26
+ * 自定义查询 redirect:传入 path,返回目标 URL(找不到返回 null)。
27
+ * 优先级高于 storefront query。
28
+ */
29
+ resolveRedirect?: (path: string) => Promise<string | null>;
30
+ /** 重定向状态码:默认 301(永久) */
31
+ status?: 301 | 302 | 307 | 308;
32
+ /** 是否仅域内 path(避免 open redirect 攻击)。默认 true */
33
+ noExternal?: boolean;
34
+ }
35
+
36
+ const URL_REDIRECTS_QUERY = /* GraphQL */ `
37
+ query UrlRedirects($first: Int!, $query: String) {
38
+ urlRedirects(first: $first, query: $query) {
39
+ nodes { path target }
40
+ }
41
+ }
42
+ `;
43
+
44
+ export async function storefrontRedirect(
45
+ options: StorefrontRedirectOptions,
46
+ ): Promise<Response | null> {
47
+ const { request, storefront, resolveRedirect, status = 301, noExternal = true } = options;
48
+ const url = new URL(request.url);
49
+ const path = url.pathname + (url.search || '');
50
+
51
+ let target: string | null = null;
52
+
53
+ if (resolveRedirect) {
54
+ target = await resolveRedirect(path).catch(() => null);
55
+ } else if (storefront) {
56
+ try {
57
+ const data: any = await storefront.query(
58
+ URL_REDIRECTS_QUERY,
59
+ { variables: { first: 1, query: `path:${path}` } },
60
+ );
61
+ const match = data?.urlRedirects?.nodes?.find((r: any) => r.path === path);
62
+ target = match?.target ?? null;
63
+ } catch {
64
+ target = null;
65
+ }
66
+ }
67
+
68
+ if (!target) return null;
69
+
70
+ if (noExternal) {
71
+ // 防 open redirect — 只允许相对路径或同域
72
+ if (target.startsWith('//') || /^https?:\/\//i.test(target)) {
73
+ try {
74
+ const t = new URL(target);
75
+ if (t.host !== url.host) return null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+ }
81
+
82
+ return new Response(null, {
83
+ status,
84
+ headers: { Location: target },
85
+ });
86
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * getSeoMeta + <Seo> — 对齐 Hydrogen 的 SEO 工具
3
+ *
4
+ * Hydrogen 通过 Remix `meta` export 返回 SeoConfig;我们这里因不绑 Remix,
5
+ * 提供一个 `getSeoMeta(config)` 函数返回 HTML `<meta>` 字符串,供 SSR
6
+ * 服务端拼到 `<head>` 里。也提供 `<Seo data={config} />` 客户端组件
7
+ * (hydrate 阶段更新 document title / meta),用作 SPA 路由切换。
8
+ *
9
+ * 用法(服务端):
10
+ *
11
+ * const seo = getSeoMeta({
12
+ * title: product.title,
13
+ * description: product.description,
14
+ * image: product.featuredImage?.url,
15
+ * url: 'https://shop/products/xxx',
16
+ * type: 'product',
17
+ * });
18
+ * // seo = { title, htmlTags: '<title>...</title><meta ...>' }
19
+ * const html = `<head>${seo.htmlTags}...</head>`;
20
+ */
21
+
22
+ export interface SeoConfig {
23
+ /** Page title — appears in <title> and og:title */
24
+ title?: string;
25
+ /** Suffix appended to title with separator " · " — usually shop name */
26
+ titleTemplate?: string;
27
+ /** Meta description / og:description */
28
+ description?: string;
29
+ /** Canonical URL / og:url */
30
+ url?: string;
31
+ /** og:image */
32
+ image?: string | { url: string; width?: number; height?: number; altText?: string };
33
+ /** og:type — "website" | "product" | "article" | ... */
34
+ type?: 'website' | 'product' | 'article' | string;
35
+ /** twitter:card — "summary" | "summary_large_image" */
36
+ twitterCard?: 'summary' | 'summary_large_image' | string;
37
+ /** Additional og:* fields */
38
+ og?: Record<string, string>;
39
+ /** robots — "index,follow" / "noindex" */
40
+ robots?: string;
41
+ /** JSON-LD structured data */
42
+ jsonLd?: Record<string, any> | Array<Record<string, any>>;
43
+ /** lang — "zh-CN" / "en" */
44
+ language?: string;
45
+ }
46
+
47
+ export interface SeoResult {
48
+ title: string;
49
+ description: string;
50
+ /** 直接拼接到 <head> 的 HTML 字符串 */
51
+ htmlTags: string;
52
+ /** 关键 meta,方便商家自己拼 */
53
+ meta: Array<{ name?: string; property?: string; content: string }>;
54
+ /** 完整 title(含 template) */
55
+ fullTitle: string;
56
+ }
57
+
58
+ function escapeHtml(s: string): string {
59
+ return String(s).replace(/[<>&"']/g, (c) =>
60
+ ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&#39;' }[c]!),
61
+ );
62
+ }
63
+
64
+ /**
65
+ * 根据 config 生成 <head> 该有的全部 SEO 标签字符串。
66
+ */
67
+ export function getSeoMeta(config: SeoConfig): SeoResult {
68
+ const {
69
+ title, titleTemplate, description = '', url, image, type = 'website',
70
+ twitterCard = 'summary_large_image', og = {}, robots, jsonLd,
71
+ } = config;
72
+
73
+ const fullTitle = title
74
+ ? (titleTemplate ? `${title} · ${titleTemplate}` : title)
75
+ : (titleTemplate || '');
76
+
77
+ const imgUrl = typeof image === 'string' ? image : image?.url;
78
+ const imgAlt = typeof image === 'object' ? image?.altText : undefined;
79
+ const imgW = typeof image === 'object' ? image?.width : undefined;
80
+ const imgH = typeof image === 'object' ? image?.height : undefined;
81
+
82
+ const meta: SeoResult['meta'] = [];
83
+ if (description) meta.push({ name: 'description', content: description });
84
+ if (robots) meta.push({ name: 'robots', content: robots });
85
+
86
+ if (fullTitle) meta.push({ property: 'og:title', content: fullTitle });
87
+ if (description) meta.push({ property: 'og:description', content: description });
88
+ if (url) meta.push({ property: 'og:url', content: url });
89
+ meta.push({ property: 'og:type', content: type });
90
+ if (imgUrl) meta.push({ property: 'og:image', content: imgUrl });
91
+ if (imgAlt) meta.push({ property: 'og:image:alt', content: imgAlt });
92
+ if (imgW) meta.push({ property: 'og:image:width', content: String(imgW) });
93
+ if (imgH) meta.push({ property: 'og:image:height', content: String(imgH) });
94
+
95
+ for (const [k, v] of Object.entries(og)) {
96
+ meta.push({ property: k.startsWith('og:') ? k : `og:${k}`, content: v });
97
+ }
98
+
99
+ meta.push({ name: 'twitter:card', content: twitterCard });
100
+ if (fullTitle) meta.push({ name: 'twitter:title', content: fullTitle });
101
+ if (description) meta.push({ name: 'twitter:description', content: description });
102
+ if (imgUrl) meta.push({ name: 'twitter:image', content: imgUrl });
103
+
104
+ const parts: string[] = [];
105
+ if (fullTitle) parts.push(`<title>${escapeHtml(fullTitle)}</title>`);
106
+ if (url) parts.push(`<link rel="canonical" href="${escapeHtml(url)}" />`);
107
+ for (const m of meta) {
108
+ const attr = m.name ? `name="${m.name}"` : `property="${m.property}"`;
109
+ parts.push(`<meta ${attr} content="${escapeHtml(m.content)}" />`);
110
+ }
111
+ if (jsonLd) {
112
+ const data = Array.isArray(jsonLd) ? jsonLd : [jsonLd];
113
+ for (const d of data) {
114
+ parts.push(`<script type="application/ld+json">${JSON.stringify(d).replace(/</g, '\\u003c')}</script>`);
115
+ }
116
+ }
117
+
118
+ return {
119
+ title: fullTitle,
120
+ description,
121
+ meta,
122
+ fullTitle,
123
+ htmlTags: parts.join('\n'),
124
+ };
125
+ }