@shopbb/helium 0.5.10 → 0.6.1
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/dist/cache/withCache.d.ts +49 -0
- package/dist/cache/withCache.d.ts.map +1 -0
- package/dist/cache/withCache.js +117 -0
- package/dist/cache/withCache.js.map +1 -0
- package/dist/components/AddToCartButton.d.ts +28 -22
- package/dist/components/AddToCartButton.d.ts.map +1 -1
- package/dist/components/AddToCartButton.js +36 -47
- package/dist/components/AddToCartButton.js.map +1 -1
- package/dist/components/BuyNowButton.d.ts +45 -0
- package/dist/components/BuyNowButton.d.ts.map +1 -0
- package/dist/components/BuyNowButton.js +49 -0
- package/dist/components/BuyNowButton.js.map +1 -0
- package/dist/components/CartCheckoutButton.d.ts +39 -0
- package/dist/components/CartCheckoutButton.d.ts.map +1 -0
- package/dist/components/CartCheckoutButton.js +32 -0
- package/dist/components/CartCheckoutButton.js.map +1 -0
- package/dist/components/CartCost.d.ts +43 -0
- package/dist/components/CartCost.d.ts.map +1 -0
- package/dist/components/CartCost.js +34 -0
- package/dist/components/CartCost.js.map +1 -0
- package/dist/components/CartForm.d.ts +201 -0
- package/dist/components/CartForm.d.ts.map +1 -0
- package/dist/components/CartForm.js +213 -0
- package/dist/components/CartForm.js.map +1 -0
- package/dist/components/CartLineProvider.d.ts +78 -0
- package/dist/components/CartLineProvider.d.ts.map +1 -0
- package/dist/components/CartLineProvider.js +46 -0
- package/dist/components/CartLineProvider.js.map +1 -0
- package/dist/components/CartLineQuantity.d.ts +24 -0
- package/dist/components/CartLineQuantity.d.ts.map +1 -0
- package/dist/components/CartLineQuantity.js +9 -0
- package/dist/components/CartLineQuantity.js.map +1 -0
- package/dist/components/DiscountSelector.d.ts.map +1 -1
- package/dist/components/DiscountSelector.js +8 -19
- package/dist/components/DiscountSelector.js.map +1 -1
- package/dist/components/Image.d.ts +18 -0
- package/dist/components/Image.d.ts.map +1 -1
- package/dist/components/Image.js +26 -0
- package/dist/components/Image.js.map +1 -1
- package/dist/components/Pagination.d.ts +82 -0
- package/dist/components/Pagination.d.ts.map +1 -0
- package/dist/components/Pagination.js +84 -0
- package/dist/components/Pagination.js.map +1 -0
- package/dist/components/RichText.d.ts +78 -0
- package/dist/components/RichText.d.ts.map +1 -0
- package/dist/components/RichText.js +93 -0
- package/dist/components/RichText.js.map +1 -0
- package/dist/components/Seo.d.ts +25 -0
- package/dist/components/Seo.d.ts.map +1 -0
- package/dist/components/Seo.js +54 -0
- package/dist/components/Seo.js.map +1 -0
- package/dist/components/hooks/useMoney.d.ts +40 -0
- package/dist/components/hooks/useMoney.d.ts.map +1 -0
- package/dist/components/hooks/useMoney.js +60 -0
- package/dist/components/hooks/useMoney.js.map +1 -0
- package/dist/components/hooks/useOptimisticCart.d.ts +50 -0
- package/dist/components/hooks/useOptimisticCart.d.ts.map +1 -0
- package/dist/components/hooks/useOptimisticCart.js +146 -0
- package/dist/components/hooks/useOptimisticCart.js.map +1 -0
- package/dist/components/index.d.ts +28 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +21 -0
- package/dist/components/index.js.map +1 -1
- package/dist/createCartHandler.d.ts.map +1 -1
- package/dist/createCartHandler.js +57 -0
- package/dist/createCartHandler.js.map +1 -1
- package/dist/csp/csp.d.ts +57 -0
- package/dist/csp/csp.d.ts.map +1 -0
- package/dist/csp/csp.js +73 -0
- package/dist/csp/csp.js.map +1 -0
- package/dist/customer/createCustomerAccountClient.d.ts +43 -0
- package/dist/customer/createCustomerAccountClient.d.ts.map +1 -0
- package/dist/customer/createCustomerAccountClient.js +68 -0
- package/dist/customer/createCustomerAccountClient.js.map +1 -0
- package/dist/handleCartFormAction.d.ts +39 -0
- package/dist/handleCartFormAction.d.ts.map +1 -0
- package/dist/handleCartFormAction.js +103 -0
- package/dist/handleCartFormAction.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/routing/storefrontRedirect.d.ts +37 -0
- package/dist/routing/storefrontRedirect.d.ts.map +1 -0
- package/dist/routing/storefrontRedirect.js +64 -0
- package/dist/routing/storefrontRedirect.js.map +1 -0
- package/dist/seo/getSeoMeta.d.ts +68 -0
- package/dist/seo/getSeoMeta.d.ts.map +1 -0
- package/dist/seo/getSeoMeta.js +89 -0
- package/dist/seo/getSeoMeta.js.map +1 -0
- package/dist/sitemap/sitemap.d.ts +55 -0
- package/dist/sitemap/sitemap.d.ts.map +1 -0
- package/dist/sitemap/sitemap.js +93 -0
- package/dist/sitemap/sitemap.js.map +1 -0
- package/dist/types.d.ts +12 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/flattenConnection.d.ts +25 -0
- package/dist/utils/flattenConnection.d.ts.map +1 -0
- package/dist/utils/flattenConnection.js +25 -0
- package/dist/utils/flattenConnection.js.map +1 -0
- package/dist/utils/parseGid.d.ts +17 -0
- package/dist/utils/parseGid.d.ts.map +1 -0
- package/dist/utils/parseGid.js +19 -0
- package/dist/utils/parseGid.js.map +1 -0
- package/package.json +1 -1
- package/src/cache/withCache.ts +144 -0
- package/src/components/AddToCartButton.tsx +94 -56
- package/src/components/BuyNowButton.tsx +135 -0
- package/src/components/CartCheckoutButton.tsx +97 -0
- package/src/components/CartCost.tsx +65 -0
- package/src/components/CartForm.tsx +311 -0
- package/src/components/CartLineProvider.tsx +77 -0
- package/src/components/CartLineQuantity.tsx +37 -0
- package/src/components/DiscountSelector.tsx +34 -45
- package/src/components/Image.tsx +27 -0
- package/src/components/Pagination.tsx +139 -0
- package/src/components/RichText.tsx +122 -0
- package/src/components/Seo.tsx +61 -0
- package/src/components/hooks/useMoney.ts +87 -0
- package/src/components/hooks/useOptimisticCart.ts +183 -0
- package/src/components/index.ts +44 -0
- package/src/createCartHandler.ts +71 -0
- package/src/csp/csp.tsx +119 -0
- package/src/customer/createCustomerAccountClient.ts +89 -0
- package/src/handleCartFormAction.ts +129 -0
- package/src/index.ts +24 -0
- package/src/routing/storefrontRedirect.ts +86 -0
- package/src/seo/getSeoMeta.ts +125 -0
- package/src/sitemap/sitemap.ts +121 -0
- package/src/types.ts +12 -1
- package/src/utils/flattenConnection.ts +33 -0
- package/src/utils/parseGid.ts +25 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <Pagination> + getPaginationVariables() — 对齐 Hydrogen Pagination
|
|
3
|
+
*
|
|
4
|
+
* GraphQL cursor-based 分页(forward + backward)。SSR 友好:URL ?cursor= / ?direction= 控制。
|
|
5
|
+
*
|
|
6
|
+
* 用法(服务端 loader):
|
|
7
|
+
*
|
|
8
|
+
* const variables = getPaginationVariables(request, { pageBy: 20 });
|
|
9
|
+
* // → { first: 20, last: undefined, startCursor: undefined, endCursor: undefined }
|
|
10
|
+
* const data = await storefront.query(PRODUCTS_QUERY, { variables });
|
|
11
|
+
*
|
|
12
|
+
* 用法(组件):
|
|
13
|
+
*
|
|
14
|
+
* <Pagination connection={data.products}>
|
|
15
|
+
* {({ nodes, hasNextPage, NextLink, hasPreviousPage, PreviousLink, state }) => (
|
|
16
|
+
* <>
|
|
17
|
+
* {hasPreviousPage && <PreviousLink>上一页</PreviousLink>}
|
|
18
|
+
* <ul>{nodes.map((n) => <li key={n.id}>{n.title}</li>)}</ul>
|
|
19
|
+
* {hasNextPage && <NextLink>下一页</NextLink>}
|
|
20
|
+
* </>
|
|
21
|
+
* )}
|
|
22
|
+
* </Pagination>
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import * as React from 'react';
|
|
26
|
+
import { flattenConnection, type Connection } from '../utils/flattenConnection';
|
|
27
|
+
|
|
28
|
+
const DEFAULT_PAGE_BY = 20;
|
|
29
|
+
|
|
30
|
+
export interface PaginationConnection<T = any> {
|
|
31
|
+
pageInfo: {
|
|
32
|
+
hasNextPage: boolean;
|
|
33
|
+
hasPreviousPage: boolean;
|
|
34
|
+
startCursor?: string | null;
|
|
35
|
+
endCursor?: string | null;
|
|
36
|
+
};
|
|
37
|
+
nodes?: T[];
|
|
38
|
+
edges?: Array<{ node: T; cursor?: string }>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PaginationVariables {
|
|
42
|
+
first?: number;
|
|
43
|
+
last?: number;
|
|
44
|
+
startCursor?: string | null;
|
|
45
|
+
endCursor?: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 从 request URL 解析分页 query params。
|
|
50
|
+
*
|
|
51
|
+
* ?direction=next&cursor=xxx → { first: 20, endCursor: 'xxx' }
|
|
52
|
+
* ?direction=previous&cursor=xxx → { last: 20, startCursor: 'xxx' }
|
|
53
|
+
* (没 param) → { first: 20 }
|
|
54
|
+
*/
|
|
55
|
+
export function getPaginationVariables(
|
|
56
|
+
request: Request,
|
|
57
|
+
options: { pageBy?: number } = {},
|
|
58
|
+
): PaginationVariables {
|
|
59
|
+
const { pageBy = DEFAULT_PAGE_BY } = options;
|
|
60
|
+
const url = new URL(request.url);
|
|
61
|
+
const direction = url.searchParams.get('direction');
|
|
62
|
+
const cursor = url.searchParams.get('cursor');
|
|
63
|
+
if (direction === 'previous' && cursor) {
|
|
64
|
+
return { last: pageBy, startCursor: cursor };
|
|
65
|
+
}
|
|
66
|
+
if (direction === 'next' && cursor) {
|
|
67
|
+
return { first: pageBy, endCursor: cursor };
|
|
68
|
+
}
|
|
69
|
+
return { first: pageBy };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface PaginationRenderProps<T> {
|
|
73
|
+
/** 拍平后的当前页 nodes */
|
|
74
|
+
nodes: T[];
|
|
75
|
+
/** Raw pageInfo(hasNextPage / hasPreviousPage 等) */
|
|
76
|
+
pageInfo: PaginationConnection['pageInfo'];
|
|
77
|
+
/** 当前 URL state */
|
|
78
|
+
state: { direction: 'next' | 'previous' | null; cursor: string | null };
|
|
79
|
+
|
|
80
|
+
hasNextPage: boolean;
|
|
81
|
+
hasPreviousPage: boolean;
|
|
82
|
+
/** "下一页" 链接组件(自动加 ?direction=next&cursor=...) */
|
|
83
|
+
NextLink: React.FC<{ children: React.ReactNode; className?: string }>;
|
|
84
|
+
/** "上一页" 链接组件 */
|
|
85
|
+
PreviousLink: React.FC<{ children: React.ReactNode; className?: string }>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface PaginationProps<T> {
|
|
89
|
+
connection: PaginationConnection<T>;
|
|
90
|
+
children: (props: PaginationRenderProps<T>) => React.ReactNode;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function Pagination<T>(props: PaginationProps<T>) {
|
|
94
|
+
const { connection, children } = props;
|
|
95
|
+
const pageInfo = connection.pageInfo;
|
|
96
|
+
const nodes = flattenConnection(connection as Connection<T>);
|
|
97
|
+
|
|
98
|
+
// 当前 URL state(hydrate 后才能访问 window.location)
|
|
99
|
+
const [urlState, setUrlState] = React.useState<{ direction: 'next' | 'previous' | null; cursor: string | null }>({
|
|
100
|
+
direction: null, cursor: null,
|
|
101
|
+
});
|
|
102
|
+
React.useEffect(() => {
|
|
103
|
+
if (typeof window === 'undefined') return;
|
|
104
|
+
const params = new URLSearchParams(window.location.search);
|
|
105
|
+
const d = params.get('direction');
|
|
106
|
+
setUrlState({
|
|
107
|
+
direction: d === 'next' || d === 'previous' ? d : null,
|
|
108
|
+
cursor: params.get('cursor'),
|
|
109
|
+
});
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
const nextHref = pageInfo.endCursor
|
|
113
|
+
? `?direction=next&cursor=${encodeURIComponent(pageInfo.endCursor)}`
|
|
114
|
+
: '#';
|
|
115
|
+
const prevHref = pageInfo.startCursor
|
|
116
|
+
? `?direction=previous&cursor=${encodeURIComponent(pageInfo.startCursor)}`
|
|
117
|
+
: '#';
|
|
118
|
+
|
|
119
|
+
const NextLink: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className }) => (
|
|
120
|
+
<a href={nextHref} className={className} data-pagination-next>{children}</a>
|
|
121
|
+
);
|
|
122
|
+
const PreviousLink: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className }) => (
|
|
123
|
+
<a href={prevHref} className={className} data-pagination-prev>{children}</a>
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<>
|
|
128
|
+
{children({
|
|
129
|
+
nodes,
|
|
130
|
+
pageInfo,
|
|
131
|
+
state: urlState,
|
|
132
|
+
hasNextPage: pageInfo.hasNextPage,
|
|
133
|
+
hasPreviousPage: pageInfo.hasPreviousPage,
|
|
134
|
+
NextLink,
|
|
135
|
+
PreviousLink,
|
|
136
|
+
})}
|
|
137
|
+
</>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <RichText> — 对齐 Hydrogen React
|
|
3
|
+
*
|
|
4
|
+
* 渲染 Shopify rich-text metafield 的 JSON 结构。Shopify rich-text 标准格式:
|
|
5
|
+
*
|
|
6
|
+
* {
|
|
7
|
+
* type: "root",
|
|
8
|
+
* children: [
|
|
9
|
+
* { type: "heading", level: 1, children: [{type: "text", value: "Hello"}] },
|
|
10
|
+
* { type: "paragraph", children: [
|
|
11
|
+
* {type: "text", value: "Hello "},
|
|
12
|
+
* {type: "text", value: "world", bold: true},
|
|
13
|
+
* {type: "link", url: "...", children: [{type: "text", value: "click"}]},
|
|
14
|
+
* ]},
|
|
15
|
+
* ],
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* 用法:
|
|
19
|
+
* const rich = JSON.parse(product.descriptionRichText);
|
|
20
|
+
* <RichText data={rich} />
|
|
21
|
+
*
|
|
22
|
+
* // 自定义节点渲染(覆盖默认)
|
|
23
|
+
* <RichText data={rich} components={{ heading: ({level, children}) =>
|
|
24
|
+
* React.createElement(`h${level}`, {className: 'my-h'}, children) }} />
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import * as React from 'react';
|
|
28
|
+
|
|
29
|
+
export interface RichTextNode {
|
|
30
|
+
type: string;
|
|
31
|
+
value?: string;
|
|
32
|
+
level?: number;
|
|
33
|
+
url?: string;
|
|
34
|
+
title?: string;
|
|
35
|
+
target?: string;
|
|
36
|
+
bold?: boolean;
|
|
37
|
+
italic?: boolean;
|
|
38
|
+
listType?: 'ordered' | 'unordered';
|
|
39
|
+
children?: RichTextNode[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RichTextComponents {
|
|
43
|
+
root?: (props: { children: React.ReactNode }) => React.ReactElement;
|
|
44
|
+
paragraph?: (props: { children: React.ReactNode }) => React.ReactElement;
|
|
45
|
+
heading?: (props: { level: number; children: React.ReactNode }) => React.ReactElement;
|
|
46
|
+
list?: (props: { listType: 'ordered' | 'unordered'; children: React.ReactNode }) => React.ReactElement;
|
|
47
|
+
listItem?: (props: { children: React.ReactNode }) => React.ReactElement;
|
|
48
|
+
link?: (props: { url: string; title?: string; target?: string; children: React.ReactNode }) => React.ReactElement;
|
|
49
|
+
text?: (props: { value: string; bold?: boolean; italic?: boolean }) => React.ReactElement;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface RichTextProps {
|
|
53
|
+
/** Rich-text JSON object(已 parse 过的)或者 JSON string */
|
|
54
|
+
data: RichTextNode | string | null | undefined;
|
|
55
|
+
/** 自定义渲染器 */
|
|
56
|
+
components?: RichTextComponents;
|
|
57
|
+
/** 包裹容器 className */
|
|
58
|
+
className?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const DEFAULTS: Required<RichTextComponents> = {
|
|
62
|
+
root: ({ children }) => <div data-rich-text>{children}</div>,
|
|
63
|
+
paragraph: ({ children }) => <p>{children}</p>,
|
|
64
|
+
heading: ({ level, children }) => React.createElement(`h${Math.min(Math.max(level, 1), 6)}`, null, children),
|
|
65
|
+
list: ({ listType, children }) => (listType === 'ordered' ? <ol>{children}</ol> : <ul>{children}</ul>),
|
|
66
|
+
listItem: ({ children }) => <li>{children}</li>,
|
|
67
|
+
link: ({ url, title, target, children }) => (
|
|
68
|
+
<a href={url} title={title} target={target} rel={target === '_blank' ? 'noopener noreferrer' : undefined}>
|
|
69
|
+
{children}
|
|
70
|
+
</a>
|
|
71
|
+
),
|
|
72
|
+
text: ({ value, bold, italic }) => {
|
|
73
|
+
let out: React.ReactNode = value;
|
|
74
|
+
if (italic) out = <em>{out}</em>;
|
|
75
|
+
if (bold) out = <strong>{out}</strong>;
|
|
76
|
+
return <>{out}</>;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function renderNode(node: RichTextNode, comps: Required<RichTextComponents>, key: number | string): React.ReactNode {
|
|
81
|
+
if (!node) return null;
|
|
82
|
+
const childrenNodes = (node.children || []).map((c, i) => renderNode(c, comps, i));
|
|
83
|
+
switch (node.type) {
|
|
84
|
+
case 'root':
|
|
85
|
+
return <React.Fragment key={key}>{comps.root({ children: childrenNodes })}</React.Fragment>;
|
|
86
|
+
case 'paragraph':
|
|
87
|
+
return <React.Fragment key={key}>{comps.paragraph({ children: childrenNodes })}</React.Fragment>;
|
|
88
|
+
case 'heading':
|
|
89
|
+
return <React.Fragment key={key}>{comps.heading({ level: node.level || 2, children: childrenNodes })}</React.Fragment>;
|
|
90
|
+
case 'list':
|
|
91
|
+
return <React.Fragment key={key}>{comps.list({ listType: node.listType || 'unordered', children: childrenNodes })}</React.Fragment>;
|
|
92
|
+
case 'list-item':
|
|
93
|
+
return <React.Fragment key={key}>{comps.listItem({ children: childrenNodes })}</React.Fragment>;
|
|
94
|
+
case 'link':
|
|
95
|
+
return <React.Fragment key={key}>{comps.link({ url: node.url || '#', title: node.title, target: node.target, children: childrenNodes })}</React.Fragment>;
|
|
96
|
+
case 'text':
|
|
97
|
+
return <React.Fragment key={key}>{comps.text({ value: node.value || '', bold: node.bold, italic: node.italic })}</React.Fragment>;
|
|
98
|
+
default:
|
|
99
|
+
// unknown type — 递归 children 不出错
|
|
100
|
+
return <React.Fragment key={key}>{childrenNodes}</React.Fragment>;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function RichText({ data, components, className }: RichTextProps) {
|
|
105
|
+
let parsed: RichTextNode | null = null;
|
|
106
|
+
if (!data) return null;
|
|
107
|
+
if (typeof data === 'string') {
|
|
108
|
+
try { parsed = JSON.parse(data); } catch { return <div className={className}>{data}</div>; }
|
|
109
|
+
} else {
|
|
110
|
+
parsed = data;
|
|
111
|
+
}
|
|
112
|
+
if (!parsed) return null;
|
|
113
|
+
|
|
114
|
+
const comps: Required<RichTextComponents> = { ...DEFAULTS, ...(components || {}) };
|
|
115
|
+
const rendered = renderNode(parsed, comps, 0);
|
|
116
|
+
|
|
117
|
+
if (className && parsed.type === 'root') {
|
|
118
|
+
// 简单包一层,给 root 加 className
|
|
119
|
+
return <div className={className} data-rich-text>{rendered}</div>;
|
|
120
|
+
}
|
|
121
|
+
return <>{rendered}</>;
|
|
122
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <Seo data={SeoConfig} /> — 对齐 Hydrogen React
|
|
3
|
+
*
|
|
4
|
+
* Hydrogen 在 Remix 里用 `meta` export 注入 head;我们在客户端用 `<Seo>`
|
|
5
|
+
* 做 SPA 路由切换时的 document.title / meta 动态更新。
|
|
6
|
+
*
|
|
7
|
+
* SSR 阶段 Seo 组件渲染为 null(不输出 DOM);服务端商家应该用 getSeoMeta
|
|
8
|
+
* 生成 head HTML 直接拼到 <head>。
|
|
9
|
+
*
|
|
10
|
+
* 用法(客户端 SPA navigate 时):
|
|
11
|
+
*
|
|
12
|
+
* <Seo data={{
|
|
13
|
+
* title: product.title,
|
|
14
|
+
* description: product.description,
|
|
15
|
+
* image: product.featuredImage?.url,
|
|
16
|
+
* url: location.href,
|
|
17
|
+
* type: 'product',
|
|
18
|
+
* }} />
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as React from 'react';
|
|
22
|
+
import { getSeoMeta, type SeoConfig } from '../seo/getSeoMeta';
|
|
23
|
+
|
|
24
|
+
export interface SeoProps {
|
|
25
|
+
data: SeoConfig;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function Seo({ data }: SeoProps) {
|
|
29
|
+
React.useEffect(() => {
|
|
30
|
+
if (typeof document === 'undefined') return;
|
|
31
|
+
const seo = getSeoMeta(data);
|
|
32
|
+
|
|
33
|
+
if (seo.fullTitle) document.title = seo.fullTitle;
|
|
34
|
+
|
|
35
|
+
// 同步关键 meta:description / og:* / twitter:*
|
|
36
|
+
for (const m of seo.meta) {
|
|
37
|
+
const attr = m.name ? 'name' : 'property';
|
|
38
|
+
const key = m.name || m.property!;
|
|
39
|
+
let el = document.querySelector(`meta[${attr}="${key}"]`) as HTMLMetaElement | null;
|
|
40
|
+
if (!el) {
|
|
41
|
+
el = document.createElement('meta');
|
|
42
|
+
el.setAttribute(attr, key);
|
|
43
|
+
document.head.appendChild(el);
|
|
44
|
+
}
|
|
45
|
+
el.setAttribute('content', m.content);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// canonical
|
|
49
|
+
if (data.url) {
|
|
50
|
+
let canon = document.querySelector('link[rel="canonical"]') as HTMLLinkElement | null;
|
|
51
|
+
if (!canon) {
|
|
52
|
+
canon = document.createElement('link');
|
|
53
|
+
canon.setAttribute('rel', 'canonical');
|
|
54
|
+
document.head.appendChild(canon);
|
|
55
|
+
}
|
|
56
|
+
canon.setAttribute('href', data.url);
|
|
57
|
+
}
|
|
58
|
+
}, [data]);
|
|
59
|
+
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useMoney — 对齐 Hydrogen React 同名 hook
|
|
3
|
+
*
|
|
4
|
+
* 把 GraphQL MoneyV2 拆成多个易用字段,便于自定义渲染。
|
|
5
|
+
*
|
|
6
|
+
* const { amount, currencyCode, currencySymbol, withoutTrailingZerosAndCurrency }
|
|
7
|
+
* = useMoney({amount: '99.00', currencyCode: 'CNY'});
|
|
8
|
+
*
|
|
9
|
+
* <div>
|
|
10
|
+
* <span>{currencySymbol}</span>
|
|
11
|
+
* <strong>{withoutTrailingZerosAndCurrency}</strong>
|
|
12
|
+
* </div>
|
|
13
|
+
*
|
|
14
|
+
* 与 <Money> 组件的关系:<Money> 是默认渲染;useMoney() 是低层 API,给商家
|
|
15
|
+
* 自定义结构用。两者背后用同一份 currency symbol 表 + 格式化逻辑。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import * as React from 'react';
|
|
19
|
+
|
|
20
|
+
export interface MoneyV2 {
|
|
21
|
+
amount: string;
|
|
22
|
+
currencyCode: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MoneyParts {
|
|
26
|
+
/** 标准化后的金额字符串:"99.00" → "99.00"(保留两位) */
|
|
27
|
+
amount: string;
|
|
28
|
+
/** 货币代码:"CNY" */
|
|
29
|
+
currencyCode: string;
|
|
30
|
+
/** 货币符号:"¥" */
|
|
31
|
+
currencySymbol: string;
|
|
32
|
+
/** 带符号 + 千分位:"¥99.00" */
|
|
33
|
+
localizedString: string;
|
|
34
|
+
/** 去掉尾零:"99.00" → "99";"99.50" 不变 */
|
|
35
|
+
withoutTrailingZeros: string;
|
|
36
|
+
/** 去掉尾零 + 货币符号:"99" / "1,250" */
|
|
37
|
+
withoutTrailingZerosAndCurrency: string;
|
|
38
|
+
/** 仅货币符号 + 金额,不带千分位(用于排版极简场景):"¥99" */
|
|
39
|
+
symbolWithoutTrailingZeros: string;
|
|
40
|
+
/** 千分位金额:"1,234.56" */
|
|
41
|
+
amountWithComma: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const CURRENCY_SYMBOL: Record<string, string> = {
|
|
45
|
+
CNY: '¥', USD: '$', EUR: '€', GBP: '£', JPY: '¥', HKD: 'HK$', KRW: '₩',
|
|
46
|
+
AUD: 'A$', CAD: 'CA$', SGD: 'S$', TWD: 'NT$', INR: '₹', BRL: 'R$',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function formatAmount(num: number, minDec: number, maxDec: number): string {
|
|
50
|
+
if (!Number.isFinite(num)) return '0';
|
|
51
|
+
const neg = num < 0;
|
|
52
|
+
const abs = Math.abs(num);
|
|
53
|
+
const fixed = abs.toFixed(maxDec);
|
|
54
|
+
const [intPart, decPart = ''] = fixed.split('.');
|
|
55
|
+
const intWithSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
56
|
+
let trimDec = decPart;
|
|
57
|
+
while (trimDec.length > minDec && trimDec.endsWith('0')) {
|
|
58
|
+
trimDec = trimDec.slice(0, -1);
|
|
59
|
+
}
|
|
60
|
+
const out = trimDec ? `${intWithSep}.${trimDec}` : intWithSep;
|
|
61
|
+
return neg ? `-${out}` : out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function useMoney(money: MoneyV2 | null | undefined): MoneyParts {
|
|
65
|
+
return React.useMemo<MoneyParts>(() => {
|
|
66
|
+
const amount = money?.amount ?? '0';
|
|
67
|
+
const currencyCode = money?.currencyCode ?? 'USD';
|
|
68
|
+
const num = Number(amount);
|
|
69
|
+
const symbol = CURRENCY_SYMBOL[currencyCode] ?? currencyCode + ' ';
|
|
70
|
+
|
|
71
|
+
const amountWithComma = formatAmount(num, 2, 2);
|
|
72
|
+
const withoutTrailingZeros = formatAmount(num, 0, 2);
|
|
73
|
+
const localizedString = `${symbol}${amountWithComma}`;
|
|
74
|
+
const withoutTrailingZerosAndCurrency = withoutTrailingZeros;
|
|
75
|
+
const symbolWithoutTrailingZeros = `${symbol}${withoutTrailingZeros}`;
|
|
76
|
+
return {
|
|
77
|
+
amount: num.toFixed(2),
|
|
78
|
+
currencyCode,
|
|
79
|
+
currencySymbol: symbol,
|
|
80
|
+
localizedString,
|
|
81
|
+
withoutTrailingZeros,
|
|
82
|
+
withoutTrailingZerosAndCurrency,
|
|
83
|
+
symbolWithoutTrailingZeros,
|
|
84
|
+
amountWithComma,
|
|
85
|
+
};
|
|
86
|
+
}, [money?.amount, money?.currencyCode]);
|
|
87
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useOptimisticCart — 对齐 Hydrogen React 同名 hook
|
|
3
|
+
*
|
|
4
|
+
* 接受 actualCart(来自 CartProvider),返回一个"叠加了所有 pending mutation"
|
|
5
|
+
* 的 cart 视图。让 UI 在网络回来之前立刻反应用户操作(数量+1 / 删除等)。
|
|
6
|
+
*
|
|
7
|
+
* const { cart: actualCart } = useCart();
|
|
8
|
+
* const cart = useOptimisticCart(actualCart);
|
|
9
|
+
* // cart 看起来已经"立刻应用了",但 fetcher 还在跑后端
|
|
10
|
+
*
|
|
11
|
+
* 实现:
|
|
12
|
+
* - <CartForm> submit 时把 pending action 注册到模块内 store
|
|
13
|
+
* - 完成后从 store 摘掉
|
|
14
|
+
* - useOptimisticCart 订阅 store,把 actualCart 复制一份 + 应用所有 pending
|
|
15
|
+
*
|
|
16
|
+
* 简化点(vs Hydrogen 完整实现):
|
|
17
|
+
* - 只支持 LinesAdd / LinesUpdate / LinesRemove(足够 cart 主流操作)
|
|
18
|
+
* - DiscountCodesUpdate / NoteUpdate 等不做 optimistic(这些在网络层用 applyCart 兜底)
|
|
19
|
+
* - 不持久化(页面刷新清空)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as React from 'react';
|
|
23
|
+
import { useCartOptional } from '../CartProvider';
|
|
24
|
+
|
|
25
|
+
// ============================================================
|
|
26
|
+
// Pending mutation store(模块级单例)
|
|
27
|
+
// ============================================================
|
|
28
|
+
|
|
29
|
+
export interface PendingMutation {
|
|
30
|
+
id: string; // unique
|
|
31
|
+
action: 'LinesAdd' | 'LinesUpdate' | 'LinesRemove';
|
|
32
|
+
inputs: any;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 关键:snapshot 必须是稳定引用,否则 useSyncExternalStore 每次比对都觉得变了 →
|
|
36
|
+
// 无限 re-render(React #185)+ hydration mismatch(#418/#423)
|
|
37
|
+
const EMPTY_SNAPSHOT: PendingMutation[] = [];
|
|
38
|
+
|
|
39
|
+
const pendingStore = {
|
|
40
|
+
items: new Map<string, PendingMutation>(),
|
|
41
|
+
listeners: new Set<() => void>(),
|
|
42
|
+
cachedSnapshot: EMPTY_SNAPSHOT as PendingMutation[],
|
|
43
|
+
|
|
44
|
+
add(m: PendingMutation) {
|
|
45
|
+
this.items.set(m.id, m);
|
|
46
|
+
this.cachedSnapshot = Array.from(this.items.values());
|
|
47
|
+
this.notify();
|
|
48
|
+
},
|
|
49
|
+
remove(id: string) {
|
|
50
|
+
if (this.items.delete(id)) {
|
|
51
|
+
this.cachedSnapshot = this.items.size === 0 ? EMPTY_SNAPSHOT : Array.from(this.items.values());
|
|
52
|
+
this.notify();
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
list(): PendingMutation[] {
|
|
56
|
+
// 返回稳定引用(只在 add/remove 时改变)— 满足 useSyncExternalStore 契约
|
|
57
|
+
return this.cachedSnapshot;
|
|
58
|
+
},
|
|
59
|
+
subscribe(fn: () => void): () => void {
|
|
60
|
+
this.listeners.add(fn);
|
|
61
|
+
return () => { this.listeners.delete(fn); };
|
|
62
|
+
},
|
|
63
|
+
notify() {
|
|
64
|
+
for (const fn of this.listeners) fn();
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Internal: CartForm 提交时调用,注册一个 pending mutation。返回 unregister。
|
|
70
|
+
*/
|
|
71
|
+
export function __registerPendingMutation(m: PendingMutation): () => void {
|
|
72
|
+
pendingStore.add(m);
|
|
73
|
+
return () => pendingStore.remove(m.id);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ============================================================
|
|
77
|
+
// useOptimisticCart hook
|
|
78
|
+
// ============================================================
|
|
79
|
+
|
|
80
|
+
export interface OptimisticCartLine {
|
|
81
|
+
id: string;
|
|
82
|
+
quantity: number;
|
|
83
|
+
isOptimistic?: boolean;
|
|
84
|
+
merchandise?: any;
|
|
85
|
+
cost?: any;
|
|
86
|
+
attributes?: any;
|
|
87
|
+
[key: string]: any;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface OptimisticCart<TCart = any> {
|
|
91
|
+
isOptimistic?: boolean;
|
|
92
|
+
totalQuantity?: number;
|
|
93
|
+
lines?: { nodes: OptimisticCartLine[]; edges?: any };
|
|
94
|
+
cost?: any;
|
|
95
|
+
[key: string]: any;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function useOptimisticCart<TCart extends OptimisticCart = OptimisticCart>(
|
|
99
|
+
actualCart: TCart | null,
|
|
100
|
+
): TCart | null {
|
|
101
|
+
// 订阅 pending store 重渲染
|
|
102
|
+
const subscribe = React.useCallback((cb: () => void) => pendingStore.subscribe(cb), []);
|
|
103
|
+
const getSnapshot = React.useCallback(() => pendingStore.list(), []);
|
|
104
|
+
const pendings = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
105
|
+
|
|
106
|
+
// 如果没有 pending,CartProvider 内部的 cart 已经是最新(因为 applyCart 同步过)
|
|
107
|
+
const fallback = useCartOptional();
|
|
108
|
+
const baseCart = (actualCart ?? (fallback?.cart as any)) as TCart | null;
|
|
109
|
+
|
|
110
|
+
return React.useMemo(() => {
|
|
111
|
+
if (!baseCart) {
|
|
112
|
+
// 如果没有 actual cart 但有 pending LinesAdd → 模拟一个空 cart + 即将添加的行
|
|
113
|
+
if (pendings.length === 0) return null;
|
|
114
|
+
const optimisticLines: OptimisticCartLine[] = [];
|
|
115
|
+
for (const p of pendings) {
|
|
116
|
+
if (p.action === 'LinesAdd' && Array.isArray(p.inputs?.lines)) {
|
|
117
|
+
for (const ln of p.inputs.lines) {
|
|
118
|
+
optimisticLines.push({
|
|
119
|
+
id: `__optimistic_${p.id}_${ln.merchandiseId}`,
|
|
120
|
+
quantity: ln.quantity ?? 1,
|
|
121
|
+
merchandise: { id: ln.merchandiseId },
|
|
122
|
+
isOptimistic: true,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (optimisticLines.length === 0) return null;
|
|
128
|
+
return {
|
|
129
|
+
isOptimistic: true,
|
|
130
|
+
totalQuantity: optimisticLines.reduce((s, l) => s + l.quantity, 0),
|
|
131
|
+
lines: { nodes: optimisticLines },
|
|
132
|
+
} as TCart;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (pendings.length === 0) return baseCart;
|
|
136
|
+
|
|
137
|
+
// 复制 lines(不深拷贝整个 cart — 只动 lines 节点列表)
|
|
138
|
+
const linesNodes = ((baseCart as any).lines?.nodes ?? []).slice() as OptimisticCartLine[];
|
|
139
|
+
|
|
140
|
+
for (const p of pendings) {
|
|
141
|
+
if (p.action === 'LinesAdd' && Array.isArray(p.inputs?.lines)) {
|
|
142
|
+
for (const ln of p.inputs.lines) {
|
|
143
|
+
// 简化:若已有相同 merchandiseId 累加,否则追加
|
|
144
|
+
const idx = linesNodes.findIndex((l) => l.merchandise?.id === ln.merchandiseId);
|
|
145
|
+
if (idx >= 0) {
|
|
146
|
+
linesNodes[idx] = {
|
|
147
|
+
...linesNodes[idx],
|
|
148
|
+
quantity: linesNodes[idx].quantity + (ln.quantity ?? 1),
|
|
149
|
+
isOptimistic: true,
|
|
150
|
+
};
|
|
151
|
+
} else {
|
|
152
|
+
linesNodes.push({
|
|
153
|
+
id: `__optimistic_${p.id}_${ln.merchandiseId}`,
|
|
154
|
+
quantity: ln.quantity ?? 1,
|
|
155
|
+
merchandise: { id: ln.merchandiseId },
|
|
156
|
+
isOptimistic: true,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} else if (p.action === 'LinesUpdate' && Array.isArray(p.inputs?.lines)) {
|
|
161
|
+
for (const ln of p.inputs.lines) {
|
|
162
|
+
const idx = linesNodes.findIndex((l) => l.id === ln.id);
|
|
163
|
+
if (idx >= 0) {
|
|
164
|
+
linesNodes[idx] = { ...linesNodes[idx], quantity: ln.quantity ?? linesNodes[idx].quantity, isOptimistic: true };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
} else if (p.action === 'LinesRemove' && Array.isArray(p.inputs?.lineIds)) {
|
|
168
|
+
for (const id of p.inputs.lineIds) {
|
|
169
|
+
const idx = linesNodes.findIndex((l) => l.id === id);
|
|
170
|
+
if (idx >= 0) linesNodes.splice(idx, 1);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const totalQuantity = linesNodes.reduce((s, l) => s + l.quantity, 0);
|
|
176
|
+
return {
|
|
177
|
+
...(baseCart as any),
|
|
178
|
+
isOptimistic: true,
|
|
179
|
+
totalQuantity,
|
|
180
|
+
lines: { ...(baseCart as any).lines, nodes: linesNodes },
|
|
181
|
+
} as TCart;
|
|
182
|
+
}, [baseCart, pendings]);
|
|
183
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
// Providers + hooks
|
|
23
23
|
export { ShopProvider, useShop, useShopOptional } from './ShopProvider';
|
|
24
24
|
export type { ShopContextValue, ShopProviderProps } from './ShopProvider';
|
|
25
|
+
// Hydrogen-compatible alias: <ShopifyProvider> 在我们叫 <ShopProvider>,提供别名让 Hydrogen 代码无痛迁移
|
|
26
|
+
export { ShopProvider as ShopifyProvider } from './ShopProvider';
|
|
27
|
+
export type { ShopProviderProps as ShopifyProviderProps } from './ShopProvider';
|
|
25
28
|
|
|
26
29
|
export { CartProvider, useCart, useCartOptional } from './CartProvider';
|
|
27
30
|
export type {
|
|
@@ -133,3 +136,44 @@ export type { DiscountSelectorProps } from './DiscountSelector';
|
|
|
133
136
|
|
|
134
137
|
// SSR / hydrate 辅助 hook
|
|
135
138
|
export { useMounted } from './hooks/useMounted';
|
|
139
|
+
export { useOptimisticCart } from './hooks/useOptimisticCart';
|
|
140
|
+
export type { OptimisticCart, OptimisticCartLine, PendingMutation } from './hooks/useOptimisticCart';
|
|
141
|
+
export { useMoney } from './hooks/useMoney';
|
|
142
|
+
export type { MoneyV2, MoneyParts } from './hooks/useMoney';
|
|
143
|
+
|
|
144
|
+
// CartForm — Hydrogen-compatible form-based cart mutation API
|
|
145
|
+
export { CartForm, CART_FORM_ACTIONS, useFetcher } from './CartForm';
|
|
146
|
+
export type {
|
|
147
|
+
CartFormProps, CartFormInput, CartFormFetcher, CartFormAction,
|
|
148
|
+
CartLineInput, CartLineUpdateInput,
|
|
149
|
+
} from './CartForm';
|
|
150
|
+
|
|
151
|
+
// Cart line context + 子组件
|
|
152
|
+
export { CartLineProvider, useCartLine, useCartLineOptional } from './CartLineProvider';
|
|
153
|
+
export type { CartLineProviderProps, CartLineLike } from './CartLineProvider';
|
|
154
|
+
export { CartLineQuantity } from './CartLineQuantity';
|
|
155
|
+
export type { CartLineQuantityProps } from './CartLineQuantity';
|
|
156
|
+
export { CartCost } from './CartCost';
|
|
157
|
+
export type { CartCostProps, MoneyValue } from './CartCost';
|
|
158
|
+
export { CartCheckoutButton } from './CartCheckoutButton';
|
|
159
|
+
export type { CartCheckoutButtonProps } from './CartCheckoutButton';
|
|
160
|
+
export { BuyNowButton } from './BuyNowButton';
|
|
161
|
+
export type { BuyNowButtonProps } from './BuyNowButton';
|
|
162
|
+
|
|
163
|
+
// Rich text
|
|
164
|
+
export { RichText } from './RichText';
|
|
165
|
+
export type { RichTextProps, RichTextNode, RichTextComponents } from './RichText';
|
|
166
|
+
|
|
167
|
+
// Pagination
|
|
168
|
+
export { Pagination, getPaginationVariables } from './Pagination';
|
|
169
|
+
export type { PaginationProps, PaginationConnection, PaginationRenderProps, PaginationVariables } from './Pagination';
|
|
170
|
+
|
|
171
|
+
// SEO
|
|
172
|
+
export { Seo } from './Seo';
|
|
173
|
+
export type { SeoProps } from './Seo';
|
|
174
|
+
|
|
175
|
+
// 通用工具
|
|
176
|
+
export { flattenConnection } from '../utils/flattenConnection';
|
|
177
|
+
export type { Connection } from '../utils/flattenConnection';
|
|
178
|
+
export { parseGid } from '../utils/parseGid';
|
|
179
|
+
export type { ParsedGid } from '../utils/parseGid';
|