@shopbb/helium 0.3.1 → 0.5.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/dist/components/AddressBookProvider.d.ts +93 -0
- package/dist/components/AddressBookProvider.d.ts.map +1 -0
- package/dist/components/AddressBookProvider.js +182 -0
- package/dist/components/AddressBookProvider.js.map +1 -0
- package/dist/components/AddressForm.d.ts +54 -0
- package/dist/components/AddressForm.d.ts.map +1 -0
- package/dist/components/AddressForm.js +87 -0
- package/dist/components/AddressForm.js.map +1 -0
- package/dist/components/AddressList.d.ts +35 -0
- package/dist/components/AddressList.d.ts.map +1 -0
- package/dist/components/AddressList.js +40 -0
- package/dist/components/AddressList.js.map +1 -0
- package/dist/components/AddressPicker.d.ts +39 -0
- package/dist/components/AddressPicker.d.ts.map +1 -0
- package/dist/components/AddressPicker.js +74 -0
- package/dist/components/AddressPicker.js.map +1 -0
- package/dist/components/CartProvider.d.ts.map +1 -1
- package/dist/components/CartProvider.js +9 -0
- package/dist/components/CartProvider.js.map +1 -1
- package/dist/components/DiscountComponents.d.ts +49 -0
- package/dist/components/DiscountComponents.d.ts.map +1 -0
- package/dist/components/DiscountComponents.js +119 -0
- package/dist/components/DiscountComponents.js.map +1 -0
- package/dist/components/DiscountProvider.d.ts +136 -0
- package/dist/components/DiscountProvider.d.ts.map +1 -0
- package/dist/components/DiscountProvider.js +262 -0
- package/dist/components/DiscountProvider.js.map +1 -0
- package/dist/components/DiscountSelector.d.ts +36 -0
- package/dist/components/DiscountSelector.d.ts.map +1 -0
- package/dist/components/DiscountSelector.js +111 -0
- package/dist/components/DiscountSelector.js.map +1 -0
- package/dist/components/index.d.ts +14 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +9 -0
- package/dist/components/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/AddressBookProvider.tsx +279 -0
- package/src/components/AddressForm.tsx +198 -0
- package/src/components/AddressList.tsx +110 -0
- package/src/components/AddressPicker.tsx +152 -0
- package/src/components/CartProvider.tsx +9 -0
- package/src/components/DiscountComponents.tsx +253 -0
- package/src/components/DiscountProvider.tsx +390 -0
- package/src/components/DiscountSelector.tsx +220 -0
- package/src/components/index.ts +61 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <AddressBookProvider> + useAddressBook()
|
|
3
|
+
*
|
|
4
|
+
* 接管买家地址簿全部能力。内部走 Customer Account GraphQL(不直接 fetch REST),
|
|
5
|
+
* 让商家代码不需要关心后端实现细节。
|
|
6
|
+
*
|
|
7
|
+
* 鉴权:内部通过 ShopProvider 拿 apiUrl,通过浏览器 localStorage 读 buyer token
|
|
8
|
+
* (key: 'shopbb:buyer_token')。商家也可以传 `tokenProvider` 自定义 token 取法。
|
|
9
|
+
*
|
|
10
|
+
* 用法:
|
|
11
|
+
* <ShopProvider {...}>
|
|
12
|
+
* <AddressBookProvider>
|
|
13
|
+
* <App />
|
|
14
|
+
* </AddressBookProvider>
|
|
15
|
+
* </ShopProvider>
|
|
16
|
+
*
|
|
17
|
+
* const { addresses, defaultAddress, createAddress, ... } = useAddressBook();
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as React from 'react';
|
|
21
|
+
import { useShop } from './ShopProvider';
|
|
22
|
+
|
|
23
|
+
export interface Address {
|
|
24
|
+
id: string;
|
|
25
|
+
firstName?: string | null;
|
|
26
|
+
lastName?: string | null;
|
|
27
|
+
company?: string | null;
|
|
28
|
+
address1?: string | null;
|
|
29
|
+
address2?: string | null;
|
|
30
|
+
city?: string | null;
|
|
31
|
+
district?: string | null;
|
|
32
|
+
province?: string | null;
|
|
33
|
+
provinceCode?: string | null;
|
|
34
|
+
country?: string | null;
|
|
35
|
+
countryCode?: string | null;
|
|
36
|
+
zip?: string | null;
|
|
37
|
+
phone?: string | null;
|
|
38
|
+
latitude?: number | null;
|
|
39
|
+
longitude?: number | null;
|
|
40
|
+
isDefault: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AddressInput {
|
|
44
|
+
firstName?: string;
|
|
45
|
+
lastName?: string;
|
|
46
|
+
company?: string;
|
|
47
|
+
address1?: string;
|
|
48
|
+
address2?: string;
|
|
49
|
+
city?: string;
|
|
50
|
+
district?: string;
|
|
51
|
+
province?: string;
|
|
52
|
+
provinceCode?: string;
|
|
53
|
+
country?: string;
|
|
54
|
+
countryCode?: string;
|
|
55
|
+
zip?: string;
|
|
56
|
+
phone?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type AddressBookStatus = 'unauthenticated' | 'loading' | 'idle' | 'updating' | 'error';
|
|
60
|
+
|
|
61
|
+
export interface AddressBookUserError {
|
|
62
|
+
field?: string[];
|
|
63
|
+
code?: string;
|
|
64
|
+
message: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AddressBookContextValue {
|
|
68
|
+
addresses: Address[];
|
|
69
|
+
defaultAddress: Address | null;
|
|
70
|
+
status: AddressBookStatus;
|
|
71
|
+
error: string | null;
|
|
72
|
+
createAddress: (input: AddressInput, asDefault?: boolean) => Promise<{ address?: Address; userErrors: AddressBookUserError[] }>;
|
|
73
|
+
updateAddress: (id: string, input: AddressInput, asDefault?: boolean) => Promise<{ address?: Address; userErrors: AddressBookUserError[] }>;
|
|
74
|
+
deleteAddress: (id: string) => Promise<{ userErrors: AddressBookUserError[] }>;
|
|
75
|
+
setDefault: (id: string) => Promise<{ userErrors: AddressBookUserError[] }>;
|
|
76
|
+
refetch: () => Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const Ctx = React.createContext<AddressBookContextValue | null>(null);
|
|
80
|
+
|
|
81
|
+
const DEFAULT_TOKEN_KEY = 'shopbb:buyer_token';
|
|
82
|
+
|
|
83
|
+
export interface AddressBookProviderProps {
|
|
84
|
+
children: React.ReactNode;
|
|
85
|
+
/** 自定义 token 取法。默认 localStorage[DEFAULT_TOKEN_KEY] */
|
|
86
|
+
tokenProvider?: () => string | null;
|
|
87
|
+
/** 自动 fetch;默认 true */
|
|
88
|
+
fetchOnMount?: boolean;
|
|
89
|
+
/** Customer Account GraphQL endpoint。默认 ${shop.apiUrl 替换为 /customer/api/2026-04/graphql} */
|
|
90
|
+
endpoint?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function AddressBookProvider(props: AddressBookProviderProps) {
|
|
94
|
+
const { children, tokenProvider, fetchOnMount = true, endpoint: endpointProp } = props;
|
|
95
|
+
const shop = useShop();
|
|
96
|
+
|
|
97
|
+
const [addresses, setAddresses] = React.useState<Address[]>([]);
|
|
98
|
+
const [defaultAddress, setDefaultAddressState] = React.useState<Address | null>(null);
|
|
99
|
+
const [status, setStatus] = React.useState<AddressBookStatus>('loading');
|
|
100
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
101
|
+
|
|
102
|
+
const endpoint = React.useMemo(() => {
|
|
103
|
+
if (endpointProp) return endpointProp;
|
|
104
|
+
try {
|
|
105
|
+
const u = new URL(shop.apiUrl);
|
|
106
|
+
return `${u.origin}/customer/api/2026-04/graphql`;
|
|
107
|
+
} catch {
|
|
108
|
+
return shop.apiUrl;
|
|
109
|
+
}
|
|
110
|
+
}, [endpointProp, shop.apiUrl]);
|
|
111
|
+
|
|
112
|
+
const getToken = React.useCallback(() => {
|
|
113
|
+
if (tokenProvider) return tokenProvider();
|
|
114
|
+
if (typeof localStorage === 'undefined') return null;
|
|
115
|
+
return localStorage.getItem(DEFAULT_TOKEN_KEY) || localStorage.getItem('shopflare:buyer_token');
|
|
116
|
+
}, [tokenProvider]);
|
|
117
|
+
|
|
118
|
+
const gql = React.useCallback(
|
|
119
|
+
async <T = any>(query: string, variables?: any): Promise<{ data?: T; errors?: any[] }> => {
|
|
120
|
+
const token = getToken();
|
|
121
|
+
if (!token) throw new Error('未登录');
|
|
122
|
+
const res = await fetch(endpoint, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
125
|
+
body: JSON.stringify({ query, variables }),
|
|
126
|
+
});
|
|
127
|
+
return res.json();
|
|
128
|
+
},
|
|
129
|
+
[endpoint, getToken],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const refetch = React.useCallback(async () => {
|
|
133
|
+
const token = getToken();
|
|
134
|
+
if (!token) {
|
|
135
|
+
setStatus('unauthenticated');
|
|
136
|
+
setAddresses([]);
|
|
137
|
+
setDefaultAddressState(null);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
setStatus('loading');
|
|
141
|
+
try {
|
|
142
|
+
const result = await gql<{ customer: { defaultAddress: Address | null; addresses: { nodes: Address[] } } | null }>(
|
|
143
|
+
`query { customer { defaultAddress { id firstName lastName phone city district province address1 address2 country zip isDefault } addresses(first: 50) { nodes { id firstName lastName company phone city district province provinceCode country countryCode address1 address2 zip latitude longitude isDefault } } } }`,
|
|
144
|
+
);
|
|
145
|
+
if (result.errors?.length) throw new Error(result.errors[0].message);
|
|
146
|
+
const c = result.data?.customer;
|
|
147
|
+
setAddresses(c?.addresses?.nodes ?? []);
|
|
148
|
+
setDefaultAddressState(c?.defaultAddress ?? null);
|
|
149
|
+
setStatus('idle');
|
|
150
|
+
setError(null);
|
|
151
|
+
} catch (e: any) {
|
|
152
|
+
setError(e?.message ?? String(e));
|
|
153
|
+
setStatus('error');
|
|
154
|
+
}
|
|
155
|
+
}, [gql, getToken]);
|
|
156
|
+
|
|
157
|
+
React.useEffect(() => {
|
|
158
|
+
if (fetchOnMount) void refetch();
|
|
159
|
+
}, [fetchOnMount, refetch]);
|
|
160
|
+
|
|
161
|
+
const createAddress = React.useCallback(
|
|
162
|
+
async (input: AddressInput, asDefault?: boolean) => {
|
|
163
|
+
setStatus('updating');
|
|
164
|
+
try {
|
|
165
|
+
const result = await gql<{ customerAddressCreate: { customerAddress: Address | null; userErrors: AddressBookUserError[] } }>(
|
|
166
|
+
`mutation Create($a: MailingAddressInput!, $d: Boolean) {
|
|
167
|
+
customerAddressCreate(address: $a, defaultAddress: $d) {
|
|
168
|
+
customerAddress { id firstName lastName phone city district province address1 address2 country zip isDefault }
|
|
169
|
+
userErrors { field code message }
|
|
170
|
+
}
|
|
171
|
+
}`,
|
|
172
|
+
{ a: input, d: asDefault ?? false },
|
|
173
|
+
);
|
|
174
|
+
if (result.errors?.length) throw new Error(result.errors[0].message);
|
|
175
|
+
const payload = result.data!.customerAddressCreate;
|
|
176
|
+
await refetch();
|
|
177
|
+
return { address: payload.customerAddress ?? undefined, userErrors: payload.userErrors };
|
|
178
|
+
} catch (e: any) {
|
|
179
|
+
setStatus('error');
|
|
180
|
+
setError(e?.message);
|
|
181
|
+
return { userErrors: [{ code: 'NETWORK_ERROR', message: e?.message ?? String(e) }] };
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
[gql, refetch],
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const updateAddress = React.useCallback(
|
|
188
|
+
async (id: string, input: AddressInput, asDefault?: boolean) => {
|
|
189
|
+
setStatus('updating');
|
|
190
|
+
try {
|
|
191
|
+
const result = await gql<{ customerAddressUpdate: { customerAddress: Address | null; userErrors: AddressBookUserError[] } }>(
|
|
192
|
+
`mutation Upd($id: ID!, $a: MailingAddressInput!, $d: Boolean) {
|
|
193
|
+
customerAddressUpdate(addressId: $id, address: $a, defaultAddress: $d) {
|
|
194
|
+
customerAddress { id firstName lastName phone city district province address1 address2 country zip isDefault }
|
|
195
|
+
userErrors { field code message }
|
|
196
|
+
}
|
|
197
|
+
}`,
|
|
198
|
+
{ id, a: input, d: asDefault },
|
|
199
|
+
);
|
|
200
|
+
if (result.errors?.length) throw new Error(result.errors[0].message);
|
|
201
|
+
const payload = result.data!.customerAddressUpdate;
|
|
202
|
+
await refetch();
|
|
203
|
+
return { address: payload.customerAddress ?? undefined, userErrors: payload.userErrors };
|
|
204
|
+
} catch (e: any) {
|
|
205
|
+
setStatus('error');
|
|
206
|
+
setError(e?.message);
|
|
207
|
+
return { userErrors: [{ code: 'NETWORK_ERROR', message: e?.message ?? String(e) }] };
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
[gql, refetch],
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const deleteAddress = React.useCallback(
|
|
214
|
+
async (id: string) => {
|
|
215
|
+
setStatus('updating');
|
|
216
|
+
try {
|
|
217
|
+
const result = await gql<{ customerAddressDelete: { deletedAddressId: string | null; userErrors: AddressBookUserError[] } }>(
|
|
218
|
+
`mutation D($id: ID!) {
|
|
219
|
+
customerAddressDelete(addressId: $id) {
|
|
220
|
+
deletedAddressId
|
|
221
|
+
userErrors { field code message }
|
|
222
|
+
}
|
|
223
|
+
}`,
|
|
224
|
+
{ id },
|
|
225
|
+
);
|
|
226
|
+
if (result.errors?.length) throw new Error(result.errors[0].message);
|
|
227
|
+
await refetch();
|
|
228
|
+
return { userErrors: result.data!.customerAddressDelete.userErrors };
|
|
229
|
+
} catch (e: any) {
|
|
230
|
+
setStatus('error');
|
|
231
|
+
setError(e?.message);
|
|
232
|
+
return { userErrors: [{ code: 'NETWORK_ERROR', message: e?.message ?? String(e) }] };
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
[gql, refetch],
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const setDefault = React.useCallback(
|
|
239
|
+
async (id: string) => {
|
|
240
|
+
setStatus('updating');
|
|
241
|
+
try {
|
|
242
|
+
const result = await gql<{ customerDefaultAddressUpdate: { customer: any; userErrors: AddressBookUserError[] } }>(
|
|
243
|
+
`mutation SD($id: ID!) {
|
|
244
|
+
customerDefaultAddressUpdate(addressId: $id) {
|
|
245
|
+
customer { id }
|
|
246
|
+
userErrors { field code message }
|
|
247
|
+
}
|
|
248
|
+
}`,
|
|
249
|
+
{ id },
|
|
250
|
+
);
|
|
251
|
+
if (result.errors?.length) throw new Error(result.errors[0].message);
|
|
252
|
+
await refetch();
|
|
253
|
+
return { userErrors: result.data!.customerDefaultAddressUpdate.userErrors };
|
|
254
|
+
} catch (e: any) {
|
|
255
|
+
setStatus('error');
|
|
256
|
+
setError(e?.message);
|
|
257
|
+
return { userErrors: [{ code: 'NETWORK_ERROR', message: e?.message ?? String(e) }] };
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
[gql, refetch],
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const value: AddressBookContextValue = React.useMemo(
|
|
264
|
+
() => ({ addresses, defaultAddress, status, error, createAddress, updateAddress, deleteAddress, setDefault, refetch }),
|
|
265
|
+
[addresses, defaultAddress, status, error, createAddress, updateAddress, deleteAddress, setDefault, refetch],
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function useAddressBook(): AddressBookContextValue {
|
|
272
|
+
const v = React.useContext(Ctx);
|
|
273
|
+
if (!v) throw new Error('useAddressBook must be used inside <AddressBookProvider>');
|
|
274
|
+
return v;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function useAddressBookOptional(): AddressBookContextValue | null {
|
|
278
|
+
return React.useContext(Ctx);
|
|
279
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <AddressForm>
|
|
3
|
+
*
|
|
4
|
+
* 地址编辑 / 新增表单。**自带**:
|
|
5
|
+
* - 表单 state(useState)
|
|
6
|
+
* - 必填验证
|
|
7
|
+
* - 调 useAddressBook().createAddress / updateAddress
|
|
8
|
+
* - userErrors 显示
|
|
9
|
+
* - loading 态
|
|
10
|
+
*
|
|
11
|
+
* 用法(新增):
|
|
12
|
+
* <AddressForm onSave={() => setCreating(false)} onCancel={...} />
|
|
13
|
+
*
|
|
14
|
+
* 用法(编辑):
|
|
15
|
+
* <AddressForm initial={editingAddress} onSave={...} onCancel={...} />
|
|
16
|
+
*
|
|
17
|
+
* 表单不带样式(用 data-* 钩子或商家自己 className 控制)。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as React from 'react';
|
|
21
|
+
import { useAddressBook, type Address, type AddressInput, type AddressBookUserError } from './AddressBookProvider';
|
|
22
|
+
|
|
23
|
+
export interface AddressFormProps {
|
|
24
|
+
/** 传 = 编辑模式;不传 = 新增模式 */
|
|
25
|
+
initial?: Address;
|
|
26
|
+
/** 保存成功后触发 */
|
|
27
|
+
onSave?: (address?: Address) => void;
|
|
28
|
+
/** 取消按钮触发 */
|
|
29
|
+
onCancel?: () => void;
|
|
30
|
+
/** 自定义提交按钮文字 */
|
|
31
|
+
submitText?: string;
|
|
32
|
+
cancelText?: string;
|
|
33
|
+
/** 显示"设为默认"复选框,默认 true */
|
|
34
|
+
showDefaultCheckbox?: boolean;
|
|
35
|
+
/** 哪些字段必填,默认 ['firstName','phone','address1','city','province'] */
|
|
36
|
+
requiredFields?: Array<keyof AddressInput>;
|
|
37
|
+
className?: string;
|
|
38
|
+
/** i18n 文案覆盖 */
|
|
39
|
+
i18n?: Partial<AddressFormI18n>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AddressFormI18n {
|
|
43
|
+
firstName: string;
|
|
44
|
+
lastName: string;
|
|
45
|
+
company: string;
|
|
46
|
+
phone: string;
|
|
47
|
+
country: string;
|
|
48
|
+
province: string;
|
|
49
|
+
city: string;
|
|
50
|
+
district: string;
|
|
51
|
+
address1: string;
|
|
52
|
+
address2: string;
|
|
53
|
+
zip: string;
|
|
54
|
+
setDefault: string;
|
|
55
|
+
saving: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEFAULT_I18N: AddressFormI18n = {
|
|
59
|
+
firstName: '收货人',
|
|
60
|
+
lastName: '姓氏',
|
|
61
|
+
company: '公司',
|
|
62
|
+
phone: '手机号',
|
|
63
|
+
country: '国家',
|
|
64
|
+
province: '省',
|
|
65
|
+
city: '市',
|
|
66
|
+
district: '区',
|
|
67
|
+
address1: '详细地址',
|
|
68
|
+
address2: '地址补充',
|
|
69
|
+
zip: '邮编',
|
|
70
|
+
setDefault: '设为默认地址',
|
|
71
|
+
saving: '保存中…',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export function AddressForm(props: AddressFormProps) {
|
|
75
|
+
const {
|
|
76
|
+
initial, onSave, onCancel,
|
|
77
|
+
submitText = '保存',
|
|
78
|
+
cancelText = '取消',
|
|
79
|
+
showDefaultCheckbox = true,
|
|
80
|
+
requiredFields = ['firstName', 'phone', 'address1', 'city', 'province'],
|
|
81
|
+
className,
|
|
82
|
+
i18n: i18nOverride,
|
|
83
|
+
} = props;
|
|
84
|
+
|
|
85
|
+
const { createAddress, updateAddress } = useAddressBook();
|
|
86
|
+
const i18n = { ...DEFAULT_I18N, ...i18nOverride };
|
|
87
|
+
|
|
88
|
+
const [form, setForm] = React.useState<AddressInput>({
|
|
89
|
+
firstName: initial?.firstName ?? '',
|
|
90
|
+
lastName: initial?.lastName ?? '',
|
|
91
|
+
company: initial?.company ?? '',
|
|
92
|
+
phone: initial?.phone ?? '',
|
|
93
|
+
country: initial?.country ?? 'China',
|
|
94
|
+
countryCode: initial?.countryCode ?? 'CN',
|
|
95
|
+
province: initial?.province ?? '',
|
|
96
|
+
provinceCode: initial?.provinceCode ?? '',
|
|
97
|
+
city: initial?.city ?? '',
|
|
98
|
+
district: initial?.district ?? '',
|
|
99
|
+
address1: initial?.address1 ?? '',
|
|
100
|
+
address2: initial?.address2 ?? '',
|
|
101
|
+
zip: initial?.zip ?? '',
|
|
102
|
+
});
|
|
103
|
+
const [asDefault, setAsDefault] = React.useState(initial?.isDefault ?? false);
|
|
104
|
+
const [saving, setSaving] = React.useState(false);
|
|
105
|
+
const [errors, setErrors] = React.useState<AddressBookUserError[]>([]);
|
|
106
|
+
|
|
107
|
+
const update = (k: keyof AddressInput, v: string) => setForm((f) => ({ ...f, [k]: v }));
|
|
108
|
+
|
|
109
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
// 客户端必填校验
|
|
112
|
+
const missing = requiredFields.filter((k) => !(form[k] && String(form[k]).trim()));
|
|
113
|
+
if (missing.length > 0) {
|
|
114
|
+
setErrors([{ field: missing as string[], code: 'MISSING_FIELD', message: '请填写完整地址' }]);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
setSaving(true);
|
|
118
|
+
setErrors([]);
|
|
119
|
+
try {
|
|
120
|
+
const result = initial
|
|
121
|
+
? await updateAddress(initial.id, form, asDefault)
|
|
122
|
+
: await createAddress(form, asDefault);
|
|
123
|
+
if (result.userErrors.length > 0) {
|
|
124
|
+
setErrors(result.userErrors);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
onSave?.(result.address);
|
|
128
|
+
} finally {
|
|
129
|
+
setSaving(false);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const isRequired = (k: keyof AddressInput) => requiredFields.includes(k);
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<form onSubmit={handleSubmit} data-address-form className={className}>
|
|
137
|
+
<div data-row>
|
|
138
|
+
<label>
|
|
139
|
+
<span>{i18n.firstName}{isRequired('firstName') && ' *'}</span>
|
|
140
|
+
<input value={form.firstName} onChange={(e) => update('firstName', e.target.value)} required={isRequired('firstName')} />
|
|
141
|
+
</label>
|
|
142
|
+
<label>
|
|
143
|
+
<span>{i18n.phone}{isRequired('phone') && ' *'}</span>
|
|
144
|
+
<input value={form.phone} onChange={(e) => update('phone', e.target.value)} required={isRequired('phone')} type="tel" />
|
|
145
|
+
</label>
|
|
146
|
+
</div>
|
|
147
|
+
<div data-row data-row-3>
|
|
148
|
+
<label>
|
|
149
|
+
<span>{i18n.province}{isRequired('province') && ' *'}</span>
|
|
150
|
+
<input value={form.province} onChange={(e) => update('province', e.target.value)} required={isRequired('province')} />
|
|
151
|
+
</label>
|
|
152
|
+
<label>
|
|
153
|
+
<span>{i18n.city}{isRequired('city') && ' *'}</span>
|
|
154
|
+
<input value={form.city} onChange={(e) => update('city', e.target.value)} required={isRequired('city')} />
|
|
155
|
+
</label>
|
|
156
|
+
<label>
|
|
157
|
+
<span>{i18n.district}</span>
|
|
158
|
+
<input value={form.district} onChange={(e) => update('district', e.target.value)} />
|
|
159
|
+
</label>
|
|
160
|
+
</div>
|
|
161
|
+
<label>
|
|
162
|
+
<span>{i18n.address1}{isRequired('address1') && ' *'}</span>
|
|
163
|
+
<input value={form.address1} onChange={(e) => update('address1', e.target.value)} required={isRequired('address1')} />
|
|
164
|
+
</label>
|
|
165
|
+
<label>
|
|
166
|
+
<span>{i18n.address2}</span>
|
|
167
|
+
<input value={form.address2} onChange={(e) => update('address2', e.target.value)} />
|
|
168
|
+
</label>
|
|
169
|
+
<div data-row>
|
|
170
|
+
<label>
|
|
171
|
+
<span>{i18n.zip}</span>
|
|
172
|
+
<input value={form.zip} onChange={(e) => update('zip', e.target.value)} />
|
|
173
|
+
</label>
|
|
174
|
+
{showDefaultCheckbox && (
|
|
175
|
+
<label data-checkbox>
|
|
176
|
+
<input type="checkbox" checked={asDefault} onChange={(e) => setAsDefault(e.target.checked)} />
|
|
177
|
+
<span>{i18n.setDefault}</span>
|
|
178
|
+
</label>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
{errors.length > 0 && (
|
|
182
|
+
<div data-form-errors>
|
|
183
|
+
{errors.map((er, i) => (
|
|
184
|
+
<div key={i} data-error>{er.message}</div>
|
|
185
|
+
))}
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
<div data-actions>
|
|
189
|
+
{onCancel && (
|
|
190
|
+
<button type="button" onClick={onCancel} disabled={saving} data-action="cancel">{cancelText}</button>
|
|
191
|
+
)}
|
|
192
|
+
<button type="submit" disabled={saving} data-action="submit">
|
|
193
|
+
{saving ? i18n.saving : submitText}
|
|
194
|
+
</button>
|
|
195
|
+
</div>
|
|
196
|
+
</form>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <AddressList>
|
|
3
|
+
*
|
|
4
|
+
* 渲染当前买家的地址列表。
|
|
5
|
+
*
|
|
6
|
+
* 用法:
|
|
7
|
+
* <AddressList
|
|
8
|
+
* onEdit={(addr) => openEditModal(addr)}
|
|
9
|
+
* onAdd={() => openCreateModal()}
|
|
10
|
+
* emptyText="还没有保存的地址"
|
|
11
|
+
* />
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as React from 'react';
|
|
15
|
+
import { useAddressBook, type Address } from './AddressBookProvider';
|
|
16
|
+
|
|
17
|
+
export interface AddressListProps {
|
|
18
|
+
/** 编辑按钮点击 */
|
|
19
|
+
onEdit?: (address: Address) => void;
|
|
20
|
+
/** "新增" 按钮点击。不传 = 不显示新增按钮(商家自己渲染外面) */
|
|
21
|
+
onAdd?: () => void;
|
|
22
|
+
/** 空状态文案 */
|
|
23
|
+
emptyText?: React.ReactNode;
|
|
24
|
+
/** 渲染单个地址卡片的覆盖(高级用法) */
|
|
25
|
+
renderItem?: (address: Address, actions: AddressListItemActions) => React.ReactNode;
|
|
26
|
+
/** 整体容器 className */
|
|
27
|
+
className?: string;
|
|
28
|
+
/** 加载中渲染 */
|
|
29
|
+
loadingFallback?: React.ReactNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AddressListItemActions {
|
|
33
|
+
setDefault: () => Promise<void>;
|
|
34
|
+
remove: () => Promise<void>;
|
|
35
|
+
edit?: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function AddressList(props: AddressListProps) {
|
|
39
|
+
const {
|
|
40
|
+
onEdit, onAdd, emptyText = '还没有保存的地址',
|
|
41
|
+
renderItem, className, loadingFallback = null,
|
|
42
|
+
} = props;
|
|
43
|
+
const { addresses, status, setDefault, deleteAddress } = useAddressBook();
|
|
44
|
+
|
|
45
|
+
if (status === 'loading' || status === 'unauthenticated') {
|
|
46
|
+
return <>{loadingFallback}</>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (addresses.length === 0) {
|
|
50
|
+
return (
|
|
51
|
+
<div data-empty className={className}>
|
|
52
|
+
<p>{emptyText}</p>
|
|
53
|
+
{onAdd && (
|
|
54
|
+
<button type="button" onClick={onAdd} data-add-address>
|
|
55
|
+
+ 新增地址
|
|
56
|
+
</button>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div data-address-list className={className}>
|
|
64
|
+
{onAdd && (
|
|
65
|
+
<div data-list-toolbar>
|
|
66
|
+
<span data-muted>{addresses.length} 个地址</span>
|
|
67
|
+
<button type="button" onClick={onAdd} data-add-address>+ 新增地址</button>
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
<div data-items>
|
|
71
|
+
{addresses.map((a) => {
|
|
72
|
+
const actions: AddressListItemActions = {
|
|
73
|
+
setDefault: async () => { await setDefault(a.id); },
|
|
74
|
+
remove: async () => { await deleteAddress(a.id); },
|
|
75
|
+
edit: onEdit ? () => onEdit(a) : undefined,
|
|
76
|
+
};
|
|
77
|
+
if (renderItem) return <React.Fragment key={a.id}>{renderItem(a, actions)}</React.Fragment>;
|
|
78
|
+
return <DefaultAddressItem key={a.id} address={a} actions={actions} />;
|
|
79
|
+
})}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 默认渲染
|
|
86
|
+
function DefaultAddressItem({ address: a, actions }: { address: Address; actions: AddressListItemActions }) {
|
|
87
|
+
return (
|
|
88
|
+
<div data-address-item data-default={a.isDefault ? '' : undefined}>
|
|
89
|
+
<div data-row>
|
|
90
|
+
<div data-name>
|
|
91
|
+
{a.firstName || ''}{a.lastName || ''}
|
|
92
|
+
{a.isDefault && <span data-default-tag>默认</span>}
|
|
93
|
+
</div>
|
|
94
|
+
<div data-actions>
|
|
95
|
+
{!a.isDefault && (
|
|
96
|
+
<button type="button" onClick={actions.setDefault} data-action="default">设为默认</button>
|
|
97
|
+
)}
|
|
98
|
+
{actions.edit && (
|
|
99
|
+
<button type="button" onClick={actions.edit} data-action="edit">编辑</button>
|
|
100
|
+
)}
|
|
101
|
+
<button type="button" onClick={actions.remove} data-action="remove" data-danger>删除</button>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
<div data-line>{a.phone || ''}</div>
|
|
105
|
+
<div data-line>
|
|
106
|
+
{a.province || ''}{a.city || ''}{a.district || ''}{a.address1 || ''}{a.address2 ? ' ' + a.address2 : ''}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|