@occluze/tether 0.7.0 → 0.7.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/README.md +21 -21
- package/dist/esm/chunks/{account-UIPWJQ3Y.js → account-CXMA5RPZ.js} +1 -1
- package/dist/esm/chunks/{account-pages-6ROA6Y4V.js → account-pages-IZO7FEOZ.js} +1 -1
- package/dist/esm/chunks/{analytics-2V632IRV.js → analytics-PG5FPRDN.js} +1 -1
- package/dist/esm/chunks/{chunk-4GCXM764.js → chunk-4HD4J5JC.js} +1 -1
- package/dist/esm/chunks/{chunk-BFTZR4OB.js → chunk-6TFMLT4A.js} +2 -2
- package/dist/esm/chunks/{chunk-T44MUL4Y.js → chunk-BBVUVPMO.js} +1 -1
- package/dist/esm/chunks/chunk-EW5SYO6L.js +1 -0
- package/dist/esm/chunks/chunk-HJY2JSNB.js +1 -0
- package/dist/esm/chunks/chunk-MQYB6BH4.js +1 -0
- package/dist/esm/chunks/{chunk-5Z5O3767.js → chunk-N3V45JPS.js} +1 -1
- package/dist/esm/chunks/{chunk-YLUULATY.js → chunk-RPMBCAGS.js} +1 -1
- package/dist/esm/chunks/{chunk-MPDWM2NN.js → chunk-VFNHIGCS.js} +1 -1
- package/dist/esm/chunks/{chunk-6OZJCAGZ.js → chunk-WT7RGGJU.js} +1 -1
- package/dist/esm/chunks/chunk-YN6LEBEN.js +1 -0
- package/dist/esm/chunks/chunk-Z6HS3H7K.js +1 -0
- package/dist/esm/chunks/{diagnose-3FEKRIEK.js → diagnose-XCEBQKBO.js} +1 -1
- package/dist/esm/chunks/extras-4HU3VSBQ.js +1 -0
- package/dist/esm/chunks/{markets-ui-FD6NI2OG.js → markets-ui-M4IQ62CT.js} +1 -1
- package/dist/esm/chunks/search-A5DS2IOS.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -2
- package/dist/tether.account-pages.js +13 -3
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +13 -3
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +18 -3
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +18 -3
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +19 -6
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +162 -112
- package/dist/tether.markets.js +18 -3
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +3 -3
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +18 -3
- package/dist/tether.search.min.js +1 -1
- package/dist/types/server/src/shared/cart-id.d.ts +2 -0
- package/dist/types/src/cart/mutations.d.ts +6 -0
- package/dist/types/src/cart/store.d.ts +5 -2
- package/dist/types/src/cart.d.ts +2 -2
- package/dist/types/src/client.d.ts +53 -19
- package/dist/types/src/config.d.ts +6 -1
- package/dist/types/src/index.d.ts +2 -2
- package/dist/types/src/product-shape.d.ts +3 -1
- package/package.json +1 -2
- package/dist/esm/chunks/chunk-56FCLJJD.js +0 -1
- package/dist/esm/chunks/chunk-A5SYCVQM.js +0 -1
- package/dist/esm/chunks/chunk-BA4LQJJQ.js +0 -1
- package/dist/esm/chunks/chunk-C5JYQCWH.js +0 -1
- package/dist/esm/chunks/extras-6POFBNOP.js +0 -1
- package/dist/esm/chunks/search-UJ73YBQ5.js +0 -1
- package/src/account/addresses-view.ts +0 -58
- package/src/account/answers.ts +0 -79
- package/src/account/markup.ts +0 -36
- package/src/account/operations.ts +0 -145
- package/src/account/order-lines.ts +0 -75
- package/src/account/order-list.ts +0 -107
- package/src/account/order-page.ts +0 -137
- package/src/account/page-answers.ts +0 -317
- package/src/account/page-events.ts +0 -253
- package/src/account/server-client.ts +0 -183
- package/src/account/subscriptions-view.ts +0 -115
- package/src/account-api.ts +0 -98
- package/src/account-marks.ts +0 -35
- package/src/account-pages.ts +0 -81
- package/src/account.ts +0 -284
- package/src/amounts.ts +0 -64
- package/src/analytics-shopify.ts +0 -246
- package/src/analytics.ts +0 -315
- package/src/api-version.ts +0 -79
- package/src/astro.ts +0 -272
- package/src/cart/cart-shape.ts +0 -25
- package/src/cart/checkout.ts +0 -101
- package/src/cart/drawer.ts +0 -125
- package/src/cart/mutations.ts +0 -237
- package/src/cart/render.ts +0 -266
- package/src/cart/store.ts +0 -279
- package/src/cart.ts +0 -345
- package/src/client.ts +0 -391
- package/src/collection.ts +0 -175
- package/src/config-sections.ts +0 -284
- package/src/config.ts +0 -480
- package/src/controls/cart-controls.ts +0 -123
- package/src/controls/discount.ts +0 -75
- package/src/controls/extras.ts +0 -54
- package/src/controls/navigation.ts +0 -77
- package/src/diagnose-markup.ts +0 -298
- package/src/diagnose.ts +0 -294
- package/src/dom.ts +0 -268
- package/src/extras.ts +0 -136
- package/src/fragments.ts +0 -261
- package/src/globals.d.ts +0 -15
- package/src/images.ts +0 -91
- package/src/index.ts +0 -415
- package/src/initial.ts +0 -101
- package/src/lazy-iife.ts +0 -197
- package/src/lazy.ts +0 -42
- package/src/loader.ts +0 -363
- package/src/market-codes.ts +0 -6
- package/src/markets-ui.ts +0 -227
- package/src/markets.ts +0 -246
- package/src/markup.ts +0 -127
- package/src/mounting.ts +0 -264
- package/src/page-market.ts +0 -110
- package/src/part-integrity.ts +0 -7
- package/src/parts/account-pages.ts +0 -7
- package/src/parts/account.ts +0 -7
- package/src/parts/analytics.ts +0 -7
- package/src/parts/diagnose.ts +0 -7
- package/src/parts/extras.ts +0 -7
- package/src/parts/markets.ts +0 -7
- package/src/parts/search.ts +0 -7
- package/src/presets/barba.ts +0 -194
- package/src/product-events.ts +0 -115
- package/src/product-render.ts +0 -218
- package/src/product-shape.ts +0 -49
- package/src/product-state.ts +0 -318
- package/src/product.ts +0 -308
- package/src/repeaters.ts +0 -161
- package/src/search.ts +0 -305
- package/src/selling-plans.ts +0 -145
- package/src/server.ts +0 -211
- package/src/shopify-values.ts +0 -91
- package/src/status.ts +0 -106
- package/src/storage.ts +0 -34
- package/src/store-html.ts +0 -359
- package/src/storefront/catalog.ts +0 -196
- package/src/storefront/json-ld.ts +0 -144
- package/src/variant-render.ts +0 -139
|
@@ -1,317 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The account pages' answers as typed values: addresses, the order page, subscriptions and what a change returns. The
|
|
3
|
-
* rules are answers.ts's: every selected field is kept, a missing one reads as `null`, and the ids the pages act on
|
|
4
|
-
* are required.
|
|
5
|
-
*/
|
|
6
|
-
import {
|
|
7
|
-
countOrZero,
|
|
8
|
-
field,
|
|
9
|
-
isRecord,
|
|
10
|
-
listOf,
|
|
11
|
-
nodesOf,
|
|
12
|
-
numberOrNull,
|
|
13
|
-
textList,
|
|
14
|
-
textOrNull,
|
|
15
|
-
} from '../../server/src/shared/json.ts';
|
|
16
|
-
import { imageOrNull, moneyOrNull, toOrderLine } from '../../server/src/shared/account-orders.ts';
|
|
17
|
-
import type { AccountImage, AccountOrderLine } from '../../server/src/shared/account-orders.ts';
|
|
18
|
-
import type { Money } from '../shopify-values.ts';
|
|
19
|
-
import { requiredId, unexpected } from './answers.ts';
|
|
20
|
-
|
|
21
|
-
/** An address as the documents select it. */
|
|
22
|
-
export interface MailingAddress {
|
|
23
|
-
id: string | null;
|
|
24
|
-
firstName: string | null;
|
|
25
|
-
lastName: string | null;
|
|
26
|
-
company: string | null;
|
|
27
|
-
address1: string | null;
|
|
28
|
-
address2: string | null;
|
|
29
|
-
city: string | null;
|
|
30
|
-
province: string | null;
|
|
31
|
-
zoneCode: string | null;
|
|
32
|
-
zip: string | null;
|
|
33
|
-
country: string | null;
|
|
34
|
-
countryCode: string | null;
|
|
35
|
-
phoneNumber: string | null;
|
|
36
|
-
/** One line per entry. */
|
|
37
|
-
formatted: string[];
|
|
38
|
-
}
|
|
39
|
-
/** One of the customer's saved addresses. */
|
|
40
|
-
export type AccountAddress = MailingAddress & { id: string };
|
|
41
|
-
export type CustomerAddress = AccountAddress & { isDefault: boolean };
|
|
42
|
-
interface ReturnReason {
|
|
43
|
-
id: string;
|
|
44
|
-
name: string | null;
|
|
45
|
-
handle: string | null;
|
|
46
|
-
}
|
|
47
|
-
/** A line of the order page's document. */
|
|
48
|
-
export type OrderDetailLine = AccountOrderLine & {
|
|
49
|
-
id: string;
|
|
50
|
-
name: string | null;
|
|
51
|
-
sku: string | null;
|
|
52
|
-
price: Money | null;
|
|
53
|
-
totalPrice: Money | null;
|
|
54
|
-
suggestedReturnReasonDefinitions: { nodes: ReturnReason[] };
|
|
55
|
-
};
|
|
56
|
-
interface Tracking {
|
|
57
|
-
company: string | null;
|
|
58
|
-
number: string | null;
|
|
59
|
-
url: string | null;
|
|
60
|
-
}
|
|
61
|
-
export interface Fulfillment {
|
|
62
|
-
id: string | null;
|
|
63
|
-
status: string | null;
|
|
64
|
-
createdAt: string | null;
|
|
65
|
-
estimatedDeliveryAt: string | null;
|
|
66
|
-
latestShipmentStatus: string | null;
|
|
67
|
-
trackingInformation: Tracking[];
|
|
68
|
-
}
|
|
69
|
-
interface ReturnableLine {
|
|
70
|
-
quantity: number;
|
|
71
|
-
lineItem: { id: string } | null;
|
|
72
|
-
}
|
|
73
|
-
/** A return Shopify opened. */
|
|
74
|
-
export interface OpenedReturn {
|
|
75
|
-
id: string;
|
|
76
|
-
status: string | null;
|
|
77
|
-
}
|
|
78
|
-
/** The order page's document. */
|
|
79
|
-
export interface OrderDetail {
|
|
80
|
-
id: string;
|
|
81
|
-
name: string | null;
|
|
82
|
-
number: number | null;
|
|
83
|
-
processedAt: string | null;
|
|
84
|
-
cancelledAt: string | null;
|
|
85
|
-
financialStatus: string | null;
|
|
86
|
-
fulfillmentStatus: string | null;
|
|
87
|
-
statusPageUrl: string | null;
|
|
88
|
-
requiresShipping: boolean | null;
|
|
89
|
-
totalPrice: Money | null;
|
|
90
|
-
subtotal: Money | null;
|
|
91
|
-
totalShipping: Money | null;
|
|
92
|
-
totalTax: Money | null;
|
|
93
|
-
shippingAddress: MailingAddress | null;
|
|
94
|
-
billingAddress: MailingAddress | null;
|
|
95
|
-
lineItems: { nodes: OrderDetailLine[] };
|
|
96
|
-
fulfillments: { nodes: Fulfillment[] };
|
|
97
|
-
returnInformation: { returnableLineItems: { nodes: ReturnableLine[] } } | null;
|
|
98
|
-
returns: { nodes: OpenedReturn[] };
|
|
99
|
-
}
|
|
100
|
-
export interface SubscriptionLine {
|
|
101
|
-
id: string | null;
|
|
102
|
-
name: string | null;
|
|
103
|
-
title: string | null;
|
|
104
|
-
variantTitle: string | null;
|
|
105
|
-
quantity: number;
|
|
106
|
-
sku: string | null;
|
|
107
|
-
currentPrice: Money | null;
|
|
108
|
-
lineDiscountedPrice: Money | null;
|
|
109
|
-
image: AccountImage | null;
|
|
110
|
-
}
|
|
111
|
-
interface BillingCycle {
|
|
112
|
-
cycleIndex: number | null;
|
|
113
|
-
skipped: boolean;
|
|
114
|
-
billingAttemptExpectedDate: string | null;
|
|
115
|
-
}
|
|
116
|
-
/** The subscriptions document. */
|
|
117
|
-
export interface SubscriptionContract {
|
|
118
|
-
id: string;
|
|
119
|
-
/** `ACTIVE`, `PAUSED`, `CANCELLED`, `EXPIRED` or `FAILED`. */
|
|
120
|
-
status: string | null;
|
|
121
|
-
nextBillingDate: string | null;
|
|
122
|
-
currencyCode: string | null;
|
|
123
|
-
deliveryPrice: Money | null;
|
|
124
|
-
deliveryPolicy: { interval: string | null; intervalCount: { count: number } | null } | null;
|
|
125
|
-
lines: { nodes: SubscriptionLine[] };
|
|
126
|
-
upcomingBillingCycles: { nodes: BillingCycle[] };
|
|
127
|
-
}
|
|
128
|
-
export interface Profile {
|
|
129
|
-
firstName: string | null;
|
|
130
|
-
lastName: string | null;
|
|
131
|
-
}
|
|
132
|
-
/** What a subscription action changed: the contract (pause, resume, cancel) or its next billing cycle (skip, unskip). */
|
|
133
|
-
export type SubscriptionChange =
|
|
134
|
-
| { contract: { id: string | null; status: string | null } }
|
|
135
|
-
| { billingCycle: { cycleIndex: number | null; skipped: boolean } };
|
|
136
|
-
|
|
137
|
-
function toMailingAddress(value: unknown): MailingAddress {
|
|
138
|
-
const text = (name: string) => textOrNull(field(value, name));
|
|
139
|
-
return {
|
|
140
|
-
id: text('id'),
|
|
141
|
-
firstName: text('firstName'),
|
|
142
|
-
lastName: text('lastName'),
|
|
143
|
-
company: text('company'),
|
|
144
|
-
address1: text('address1'),
|
|
145
|
-
address2: text('address2'),
|
|
146
|
-
city: text('city'),
|
|
147
|
-
province: text('province'),
|
|
148
|
-
zoneCode: text('zoneCode'),
|
|
149
|
-
zip: text('zip'),
|
|
150
|
-
country: text('country'),
|
|
151
|
-
countryCode: text('countryCode'),
|
|
152
|
-
phoneNumber: text('phoneNumber'),
|
|
153
|
-
formatted: textList(field(value, 'formatted')),
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const mailingAddressOrNull = (value: unknown): MailingAddress | null =>
|
|
158
|
-
isRecord(value) ? toMailingAddress(value) : null;
|
|
159
|
-
|
|
160
|
-
/** @throws {TetherError} Without an id. */
|
|
161
|
-
export const toAccountAddress = (value: unknown): AccountAddress => ({
|
|
162
|
-
...toMailingAddress(value),
|
|
163
|
-
id: requiredId(field(value, 'id'), 'address id'),
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* @param value The addresses operation's list, each address with the `isDefault` it adds.
|
|
168
|
-
* @throws {TetherError} For an address without an id.
|
|
169
|
-
*/
|
|
170
|
-
export const toCustomerAddresses = (value: unknown): CustomerAddress[] =>
|
|
171
|
-
listOf(value, (address) => ({ ...toAccountAddress(address), isDefault: field(address, 'isDefault') === true }));
|
|
172
|
-
|
|
173
|
-
const toOrderDetailLine = (value: unknown): OrderDetailLine => ({
|
|
174
|
-
...toOrderLine(value),
|
|
175
|
-
id: requiredId(field(value, 'id'), 'line item id'),
|
|
176
|
-
name: textOrNull(field(value, 'name')),
|
|
177
|
-
sku: textOrNull(field(value, 'sku')),
|
|
178
|
-
price: moneyOrNull(field(value, 'price')),
|
|
179
|
-
totalPrice: moneyOrNull(field(value, 'totalPrice')),
|
|
180
|
-
suggestedReturnReasonDefinitions: nodesOf(field(value, 'suggestedReturnReasonDefinitions'), (reason) => ({
|
|
181
|
-
id: requiredId(field(reason, 'id'), 'return reason id'),
|
|
182
|
-
name: textOrNull(field(reason, 'name')),
|
|
183
|
-
handle: textOrNull(field(reason, 'handle')),
|
|
184
|
-
})),
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
const toFulfillment = (value: unknown): Fulfillment => ({
|
|
188
|
-
id: textOrNull(field(value, 'id')),
|
|
189
|
-
status: textOrNull(field(value, 'status')),
|
|
190
|
-
createdAt: textOrNull(field(value, 'createdAt')),
|
|
191
|
-
estimatedDeliveryAt: textOrNull(field(value, 'estimatedDeliveryAt')),
|
|
192
|
-
latestShipmentStatus: textOrNull(field(value, 'latestShipmentStatus')),
|
|
193
|
-
trackingInformation: listOf(field(value, 'trackingInformation'), (tracking) => ({
|
|
194
|
-
company: textOrNull(field(tracking, 'company')),
|
|
195
|
-
number: textOrNull(field(tracking, 'number')),
|
|
196
|
-
url: textOrNull(field(tracking, 'url')),
|
|
197
|
-
})),
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
function toReturnableLine(value: unknown): ReturnableLine {
|
|
201
|
-
const lineId = textOrNull(field(field(value, 'lineItem'), 'id'));
|
|
202
|
-
return { quantity: countOrZero(field(value, 'quantity')), lineItem: lineId === null ? null : { id: lineId } };
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function toReturnInformation(value: unknown): OrderDetail['returnInformation'] {
|
|
206
|
-
if (!isRecord(value)) return null;
|
|
207
|
-
return { returnableLineItems: nodesOf(value['returnableLineItems'], toReturnableLine) };
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* @param value The order operation's `order`.
|
|
212
|
-
* @returns `null` when the customer has no such order.
|
|
213
|
-
* @throws {TetherError} For an order, line, reason or return without an id.
|
|
214
|
-
*/
|
|
215
|
-
export function toOrderDetail(value: unknown): OrderDetail | null {
|
|
216
|
-
if (!isRecord(value)) return null;
|
|
217
|
-
const text = (name: string) => textOrNull(value[name]);
|
|
218
|
-
const money = (name: string) => moneyOrNull(value[name]);
|
|
219
|
-
const requiresShipping = value['requiresShipping'];
|
|
220
|
-
return {
|
|
221
|
-
id: requiredId(value['id'], 'order id'),
|
|
222
|
-
name: text('name'),
|
|
223
|
-
number: numberOrNull(value['number']),
|
|
224
|
-
processedAt: text('processedAt'),
|
|
225
|
-
cancelledAt: text('cancelledAt'),
|
|
226
|
-
financialStatus: text('financialStatus'),
|
|
227
|
-
fulfillmentStatus: text('fulfillmentStatus'),
|
|
228
|
-
statusPageUrl: text('statusPageUrl'),
|
|
229
|
-
requiresShipping: typeof requiresShipping === 'boolean' ? requiresShipping : null,
|
|
230
|
-
totalPrice: money('totalPrice'),
|
|
231
|
-
subtotal: money('subtotal'),
|
|
232
|
-
totalShipping: money('totalShipping'),
|
|
233
|
-
totalTax: money('totalTax'),
|
|
234
|
-
shippingAddress: mailingAddressOrNull(value['shippingAddress']),
|
|
235
|
-
billingAddress: mailingAddressOrNull(value['billingAddress']),
|
|
236
|
-
lineItems: nodesOf(value['lineItems'], toOrderDetailLine),
|
|
237
|
-
fulfillments: nodesOf(value['fulfillments'], toFulfillment),
|
|
238
|
-
returnInformation: toReturnInformation(value['returnInformation']),
|
|
239
|
-
returns: nodesOf(value['returns'], toReturn),
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
const toSubscriptionLine = (value: unknown): SubscriptionLine => ({
|
|
244
|
-
id: textOrNull(field(value, 'id')),
|
|
245
|
-
name: textOrNull(field(value, 'name')),
|
|
246
|
-
title: textOrNull(field(value, 'title')),
|
|
247
|
-
variantTitle: textOrNull(field(value, 'variantTitle')),
|
|
248
|
-
quantity: countOrZero(field(value, 'quantity')),
|
|
249
|
-
sku: textOrNull(field(value, 'sku')),
|
|
250
|
-
currentPrice: moneyOrNull(field(value, 'currentPrice')),
|
|
251
|
-
lineDiscountedPrice: moneyOrNull(field(value, 'lineDiscountedPrice')),
|
|
252
|
-
image: imageOrNull(field(value, 'image')),
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
const toBillingCycle = (value: unknown): BillingCycle => ({
|
|
256
|
-
cycleIndex: numberOrNull(field(value, 'cycleIndex')),
|
|
257
|
-
skipped: field(value, 'skipped') === true,
|
|
258
|
-
billingAttemptExpectedDate: textOrNull(field(value, 'billingAttemptExpectedDate')),
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
function toDeliveryPolicy(value: unknown): SubscriptionContract['deliveryPolicy'] {
|
|
262
|
-
if (!isRecord(value)) return null;
|
|
263
|
-
const intervalCount = value['intervalCount'];
|
|
264
|
-
return {
|
|
265
|
-
interval: textOrNull(value['interval']),
|
|
266
|
-
intervalCount: isRecord(intervalCount) ? { count: countOrZero(intervalCount['count']) } : null,
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/** @throws {TetherError} Without an id. */
|
|
271
|
-
const toSubscriptionContract = (value: unknown): SubscriptionContract => ({
|
|
272
|
-
id: requiredId(field(value, 'id'), 'subscription id'),
|
|
273
|
-
status: textOrNull(field(value, 'status')),
|
|
274
|
-
nextBillingDate: textOrNull(field(value, 'nextBillingDate')),
|
|
275
|
-
currencyCode: textOrNull(field(value, 'currencyCode')),
|
|
276
|
-
deliveryPrice: moneyOrNull(field(value, 'deliveryPrice')),
|
|
277
|
-
deliveryPolicy: toDeliveryPolicy(field(value, 'deliveryPolicy')),
|
|
278
|
-
lines: nodesOf(field(value, 'lines'), toSubscriptionLine),
|
|
279
|
-
upcomingBillingCycles: nodesOf(field(value, 'upcomingBillingCycles'), toBillingCycle),
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* @param value The subscriptions operation's list.
|
|
284
|
-
* @throws {TetherError} For a contract without an id.
|
|
285
|
-
*/
|
|
286
|
-
export const toSubscriptionContracts = (value: unknown): SubscriptionContract[] =>
|
|
287
|
-
listOf(value, toSubscriptionContract);
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* @param result The subscription operation's result.
|
|
291
|
-
* @throws {TetherError} With neither a contract nor a billing cycle.
|
|
292
|
-
*/
|
|
293
|
-
export function toSubscriptionChange(result: Record<string, unknown>): SubscriptionChange {
|
|
294
|
-
const { contract, billingCycle } = result;
|
|
295
|
-
if (isRecord(contract)) {
|
|
296
|
-
return { contract: { id: textOrNull(contract['id']), status: textOrNull(contract['status']) } };
|
|
297
|
-
}
|
|
298
|
-
if (isRecord(billingCycle)) {
|
|
299
|
-
const { cycleIndex, skipped } = toBillingCycle(billingCycle);
|
|
300
|
-
return { billingCycle: { cycleIndex, skipped } };
|
|
301
|
-
}
|
|
302
|
-
throw unexpected('changed subscription');
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/** @throws {TetherError} Without an id. */
|
|
306
|
-
export function toReturn(value: unknown): OpenedReturn {
|
|
307
|
-
return { id: requiredId(field(value, 'id'), 'return id'), status: textOrNull(field(value, 'status')) };
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/** @throws {TetherError} Without an id. */
|
|
311
|
-
export const toDeletedId = (value: unknown): string => requiredId(value, 'deleted address id');
|
|
312
|
-
|
|
313
|
-
/** @param value The profile operation's `customer`. */
|
|
314
|
-
export const toProfile = (value: unknown): Profile => ({
|
|
315
|
-
firstName: textOrNull(field(value, 'firstName')),
|
|
316
|
-
lastName: textOrNull(field(value, 'lastName')),
|
|
317
|
-
});
|
|
@@ -1,253 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* What the visitor does on the account pages: the profile and address forms, the address buttons, subscription
|
|
3
|
-
* actions and return requests. Each change marks its form or row busy (`aria-busy`, `is-busy`), then sets
|
|
4
|
-
* `tether-form-state` to `saved` or `error` with Shopify's message in `tether-form-error`.
|
|
5
|
-
*/
|
|
6
|
-
import { attr, get, qsa, sel, setText } from '../dom.ts';
|
|
7
|
-
import {
|
|
8
|
-
saveAddress,
|
|
9
|
-
deleteAddress,
|
|
10
|
-
subscriptionAction,
|
|
11
|
-
requestReturn,
|
|
12
|
-
updateProfile,
|
|
13
|
-
pagesContext,
|
|
14
|
-
} from './operations.ts';
|
|
15
|
-
import type { ReturnLine } from './operations.ts';
|
|
16
|
-
import { renderAddresses, shownAddress } from './addresses-view.ts';
|
|
17
|
-
import { renderSubscriptions } from './subscriptions-view.ts';
|
|
18
|
-
import { renderOrder } from './order-page.ts';
|
|
19
|
-
import { TetherError } from '../client.ts';
|
|
20
|
-
import type { SubscriptionAction } from '../../server/src/shared/account-routes.ts';
|
|
21
|
-
|
|
22
|
-
const SUBSCRIPTION_ACTIONS: readonly SubscriptionAction[] = ['pause', 'resume', 'skip', 'unskip', 'cancel'];
|
|
23
|
-
|
|
24
|
-
const isSubscriptionAction = (value: string | null): value is SubscriptionAction =>
|
|
25
|
-
SUBSCRIPTION_ACTIONS.some((action) => action === value);
|
|
26
|
-
|
|
27
|
-
const isField = (el: Element): el is HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement =>
|
|
28
|
-
el instanceof window.HTMLInputElement ||
|
|
29
|
-
el instanceof window.HTMLSelectElement ||
|
|
30
|
-
el instanceof window.HTMLTextAreaElement;
|
|
31
|
-
|
|
32
|
-
const isForm = (el: Element): el is HTMLFormElement => el instanceof window.HTMLFormElement;
|
|
33
|
-
|
|
34
|
-
/** @returns The form's text fields by name. */
|
|
35
|
-
function fieldsOf(form: HTMLFormElement): Record<string, string> {
|
|
36
|
-
const fields: Record<string, string> = {};
|
|
37
|
-
for (const field of form.elements) {
|
|
38
|
-
if (!isField(field)) continue;
|
|
39
|
-
if (!field.name || field.type === 'submit' || field.type === 'button' || field.type === 'checkbox') continue;
|
|
40
|
-
fields[field.name] = field.value;
|
|
41
|
-
}
|
|
42
|
-
return fields;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** @returns What to tell the customer: Shopify's own messages when it refused the input. */
|
|
46
|
-
function customerMessage(error: unknown): string {
|
|
47
|
-
const userErrors = error instanceof TetherError ? (error.userErrors ?? []) : [];
|
|
48
|
-
const message = userErrors.map((userError) => userError.message).join(' ');
|
|
49
|
-
if (message) return message;
|
|
50
|
-
const text = error instanceof Error && error.message ? error.message : 'Something went wrong';
|
|
51
|
-
return text.replace(/^Customer accounts: /, '');
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Runs one change for `el`; a second one while it is on its way (Enter, a double click) is ignored. The failure is
|
|
56
|
-
* shown in `el` (`tether-form-error`), the place the customer looks.
|
|
57
|
-
*/
|
|
58
|
-
async function act(el: HTMLElement, change: () => Promise<void>): Promise<'saved' | 'error' | 'busy'> {
|
|
59
|
-
if (el.getAttribute('aria-busy') === 'true') return 'busy';
|
|
60
|
-
el.classList.add('is-busy');
|
|
61
|
-
el.setAttribute('aria-busy', 'true');
|
|
62
|
-
el.setAttribute(attr('form-state'), 'pending');
|
|
63
|
-
const errorBoxes = qsa(el, sel('form-error'));
|
|
64
|
-
errorBoxes.forEach((box) => {
|
|
65
|
-
setText(box, '');
|
|
66
|
-
});
|
|
67
|
-
try {
|
|
68
|
-
await change();
|
|
69
|
-
el.setAttribute(attr('form-state'), 'saved');
|
|
70
|
-
return 'saved';
|
|
71
|
-
} catch (error) {
|
|
72
|
-
el.setAttribute(attr('form-state'), 'error');
|
|
73
|
-
errorBoxes.forEach((box) => {
|
|
74
|
-
setText(box, customerMessage(error));
|
|
75
|
-
});
|
|
76
|
-
return 'error';
|
|
77
|
-
} finally {
|
|
78
|
-
el.classList.remove('is-busy');
|
|
79
|
-
el.removeAttribute('aria-busy');
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** @returns True when the button asks no `tether-confirm` question, or the visitor agreed. */
|
|
84
|
-
const confirmed = (button: Element): boolean => {
|
|
85
|
-
const question = get(button, 'confirm');
|
|
86
|
-
return !question || window.confirm(question);
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const addressFormReset = (form: HTMLFormElement): void => {
|
|
90
|
-
form.reset();
|
|
91
|
-
delete form.dataset['tetherAddressId'];
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
function onSubmit(event: SubmitEvent): void {
|
|
95
|
-
const form = event.target;
|
|
96
|
-
if (!(form instanceof window.HTMLFormElement)) return;
|
|
97
|
-
if (form.matches(sel('profile-form'))) {
|
|
98
|
-
event.preventDefault();
|
|
99
|
-
void act(form, async () => {
|
|
100
|
-
await updateProfile(fieldsOf(form));
|
|
101
|
-
await pagesContext().bindCustomer(document);
|
|
102
|
-
});
|
|
103
|
-
} else if (form.matches(sel('address-form'))) {
|
|
104
|
-
event.preventDefault();
|
|
105
|
-
const id = form.dataset['tetherAddressId'];
|
|
106
|
-
const box = form.elements.namedItem('isDefault');
|
|
107
|
-
const isDefault = box instanceof window.HTMLInputElement && box.checked;
|
|
108
|
-
void act(form, async () => {
|
|
109
|
-
await saveAddress(fieldsOf(form), { id, isDefault });
|
|
110
|
-
addressFormReset(form);
|
|
111
|
-
await renderAddresses(document);
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Fills the address form with a shown address, to edit it. */
|
|
117
|
-
function editAddress(id: string): void {
|
|
118
|
-
const address = shownAddress(id);
|
|
119
|
-
const form = document.querySelector(sel('address-form'));
|
|
120
|
-
if (!address || !form || !isForm(form)) return;
|
|
121
|
-
form.dataset['tetherAddressId'] = address.id;
|
|
122
|
-
const valuesByName: Readonly<Record<string, unknown>> = { ...address };
|
|
123
|
-
for (const field of form.elements) {
|
|
124
|
-
if (!isField(field)) continue;
|
|
125
|
-
if (field.name === 'isDefault') {
|
|
126
|
-
if (field instanceof window.HTMLInputElement) field.checked = address.isDefault;
|
|
127
|
-
} else if (field.name && Object.hasOwn(valuesByName, field.name)) {
|
|
128
|
-
const value = valuesByName[field.name];
|
|
129
|
-
field.value = typeof value === 'string' ? value : '';
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
form.scrollIntoView({ block: 'nearest' });
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** @returns True when the click was on an address button. */
|
|
136
|
-
function onAddressClick(event: MouseEvent, target: Element): boolean {
|
|
137
|
-
const row = target.closest('[data-tether-address]');
|
|
138
|
-
const id = row instanceof window.HTMLElement ? row.dataset['tetherAddress'] : undefined;
|
|
139
|
-
if (target.closest(sel('address-new'))) {
|
|
140
|
-
const form = document.querySelector(sel('address-form'));
|
|
141
|
-
if (form && isForm(form)) addressFormReset(form);
|
|
142
|
-
return true;
|
|
143
|
-
}
|
|
144
|
-
if (!(row instanceof window.HTMLElement) || !id) return false;
|
|
145
|
-
const deleteButton = target.closest(sel('address-delete'));
|
|
146
|
-
if (deleteButton) {
|
|
147
|
-
event.preventDefault();
|
|
148
|
-
if (!confirmed(deleteButton)) return true;
|
|
149
|
-
void act(row, async () => {
|
|
150
|
-
await deleteAddress(id);
|
|
151
|
-
await renderAddresses(document);
|
|
152
|
-
});
|
|
153
|
-
} else if (target.closest(sel('address-default'))) {
|
|
154
|
-
event.preventDefault();
|
|
155
|
-
void act(row, async () => {
|
|
156
|
-
await saveAddress(null, { id, isDefault: true });
|
|
157
|
-
await renderAddresses(document);
|
|
158
|
-
});
|
|
159
|
-
} else if (target.closest(sel('address-edit'))) {
|
|
160
|
-
event.preventDefault();
|
|
161
|
-
editAddress(id);
|
|
162
|
-
} else {
|
|
163
|
-
return false;
|
|
164
|
-
}
|
|
165
|
-
return true;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** @param button A `tether-subscription-action` button. */
|
|
169
|
-
function onSubscriptionAction(button: HTMLElement): void {
|
|
170
|
-
const row = button.closest('[data-tether-subscription]');
|
|
171
|
-
const action = get(button, 'subscription-action');
|
|
172
|
-
const disabled = ('disabled' in button && button.disabled === true) || button.classList.contains('is-disabled');
|
|
173
|
-
if (!(row instanceof window.HTMLElement) || !isSubscriptionAction(action) || disabled || !confirmed(button)) return;
|
|
174
|
-
const id = row.dataset['tetherSubscription'] ?? '';
|
|
175
|
-
row.setAttribute(attr('subscription-state'), 'pending');
|
|
176
|
-
void act(row, async () => {
|
|
177
|
-
await subscriptionAction(id, action);
|
|
178
|
-
await renderSubscriptions(document);
|
|
179
|
-
}).then((outcome) => {
|
|
180
|
-
const state = outcome === 'error' ? 'error' : 'done';
|
|
181
|
-
// The re-render may have replaced the row: mark the one that shows this subscription now.
|
|
182
|
-
qsa(document, '[data-tether-subscription]')
|
|
183
|
-
.filter((candidate) => candidate === row || candidate.dataset['tetherSubscription'] === id)
|
|
184
|
-
.forEach((candidate) => {
|
|
185
|
-
candidate.setAttribute(attr('subscription-state'), state);
|
|
186
|
-
});
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** @param row A line row of the order page. */
|
|
191
|
-
function returnLineOf(row: HTMLElement): ReturnLine {
|
|
192
|
-
const quantity = row.querySelector(sel('return-quantity'));
|
|
193
|
-
const reason = row.querySelector(sel('return-reason'));
|
|
194
|
-
const note = row.querySelector(sel('return-note'));
|
|
195
|
-
const valueOf = (field: Element | null) =>
|
|
196
|
-
field && 'value' in field && typeof field.value === 'string' ? field.value : '';
|
|
197
|
-
const line: ReturnLine = {
|
|
198
|
-
lineItemId: row.dataset['tetherLine'] ?? '',
|
|
199
|
-
quantity: Number.parseInt(valueOf(quantity) || '0', 10),
|
|
200
|
-
};
|
|
201
|
-
const reasonId = valueOf(reason);
|
|
202
|
-
if (reasonId) line.reasonId = reasonId;
|
|
203
|
-
const noteText = valueOf(note);
|
|
204
|
-
if (noteText) line.note = noteText;
|
|
205
|
-
return line;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** @param button A `tether-order-return` button. */
|
|
209
|
-
function onReturnRequest(button: HTMLElement): void {
|
|
210
|
-
const scope = button.closest(sel('order'));
|
|
211
|
-
if (!(scope instanceof window.HTMLElement)) return;
|
|
212
|
-
const orderId = scope.dataset['tetherOrderId'];
|
|
213
|
-
if (!orderId) return;
|
|
214
|
-
const lines = qsa(scope, '[data-tether-line]')
|
|
215
|
-
.map(returnLineOf)
|
|
216
|
-
.filter((line) => line.quantity > 0);
|
|
217
|
-
if (!lines.length || !confirmed(button)) return;
|
|
218
|
-
void act(scope, async () => {
|
|
219
|
-
await requestReturn(orderId, lines);
|
|
220
|
-
scope.dataset['tetherReturned'] = '1';
|
|
221
|
-
await renderOrder(scope);
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function onClick(event: MouseEvent): void {
|
|
226
|
-
const target = event.target;
|
|
227
|
-
if (!(target instanceof window.Element)) return;
|
|
228
|
-
if (onAddressClick(event, target)) return;
|
|
229
|
-
const subscriptionButton = target.closest(sel('subscription-action'));
|
|
230
|
-
if (subscriptionButton instanceof window.HTMLElement) {
|
|
231
|
-
event.preventDefault();
|
|
232
|
-
onSubscriptionAction(subscriptionButton);
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
const returnButton = target.closest(sel('order-return'));
|
|
236
|
-
if (returnButton instanceof window.HTMLElement) {
|
|
237
|
-
event.preventDefault();
|
|
238
|
-
onReturnRequest(returnButton);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/** Listens on the document once, for every account page rendered into it. */
|
|
243
|
-
export function listenToPages(): void {
|
|
244
|
-
// Capture: before a router (Astro's ClientRouter) turns the submit into a navigation with the fields in the URL.
|
|
245
|
-
document.addEventListener('submit', onSubmit, true);
|
|
246
|
-
document.addEventListener('input', (event) => {
|
|
247
|
-
const target = event.target;
|
|
248
|
-
if (target instanceof window.HTMLElement && target.closest(sel('profile-form'))) {
|
|
249
|
-
target.dataset['tetherTouched'] = '1';
|
|
250
|
-
}
|
|
251
|
-
});
|
|
252
|
-
document.addEventListener('click', onClick);
|
|
253
|
-
}
|