@shoppexio/storefront-react 0.3.0 → 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/CHANGELOG.md +31 -0
- package/dist/account.css +26 -8
- package/dist/account.js +3217 -1001
- package/dist/account.js.map +1 -1
- package/dist/index.d.ts +188 -14
- package/dist/index.js +43 -20
- package/dist/index.js.map +1 -1
- package/eject/account/account-downloads.ts +89 -1
- package/eject/account/account-payment-method.ts +1 -0
- package/eject/account/account-referrals.ts +70 -1
- package/eject/account/account-reseller.ts +6 -140
- package/eject/account/account-subscriptions.ts +158 -6
- package/eject/account/buyer-invoice-status.ts +100 -0
- package/eject/account/downloads.tsx +118 -17
- package/eject/account/hosted-page.ts +45 -0
- package/eject/account/messages/ar.json +150 -39
- package/eject/account/messages/de.json +150 -39
- package/eject/account/messages/en.json +150 -39
- package/eject/account/messages/es.json +150 -39
- package/eject/account/messages/fi.json +150 -39
- package/eject/account/messages/fr.json +150 -39
- package/eject/account/messages/hi.json +150 -39
- package/eject/account/messages/it.json +150 -39
- package/eject/account/messages/lt.json +150 -39
- package/eject/account/messages/nl.json +150 -39
- package/eject/account/messages/pl.json +150 -39
- package/eject/account/messages/pt.json +150 -39
- package/eject/account/messages/ru.json +150 -39
- package/eject/account/messages/tr.json +150 -39
- package/eject/account/messages/uk.json +150 -39
- package/eject/account/messages/zh.json +150 -39
- package/eject/account/orders.tsx +203 -41
- package/eject/account/overview.tsx +6 -0
- package/eject/account/referrals.tsx +47 -5
- package/eject/account/reseller.tsx +62 -308
- package/eject/account/rewards.tsx +5 -0
- package/eject/account/shared.tsx +60 -4
- package/eject/account/subscriptions.tsx +387 -50
- package/package.json +4 -4
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { SdkCustomerDeliverable, SdkCustomerLicense } from '@shoppexio/storefront-react';
|
|
1
|
+
import type { SdkCustomerDeliverable, SdkCustomerFileAccess, SdkCustomerLicense } from '@shoppexio/storefront-react';
|
|
2
|
+
import { getActiveLocale, t } from './i18n';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* The downloads panel's pure half.
|
|
@@ -116,3 +117,90 @@ export function offersRenewal(license: SdkCustomerLicense): boolean {
|
|
|
116
117
|
return license.renewable && license.expires_at !== null;
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
|
|
121
|
+
/** What one file still allows, as the row says it (Design Batch 2, F1). */
|
|
122
|
+
export interface FileAccessCopy {
|
|
123
|
+
/** What is left and until when, or why it stopped. Null: nothing to say (unlimited, no end date). */
|
|
124
|
+
text: string | null;
|
|
125
|
+
/** The file no longer downloads, so the row offers no Download. */
|
|
126
|
+
refused: boolean;
|
|
127
|
+
/** The buyer can ask the shop to open it again. A refund closed it for a reason the shop already decided. */
|
|
128
|
+
canAsk: boolean;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A file's end date, as a day in the SHOP's calendar.
|
|
133
|
+
*
|
|
134
|
+
* The producer sends an instant and the shop's IANA zone. An end at 23:30 in
|
|
135
|
+
* Berlin is already the next day in UTC and still the same day in New York, so
|
|
136
|
+
* formatting in the viewer's zone would name a day the shop never set.
|
|
137
|
+
*/
|
|
138
|
+
export function fileAccessDate(endsAt: string, timeZone: string): string {
|
|
139
|
+
const date = new Date(endsAt);
|
|
140
|
+
if (Number.isNaN(date.getTime())) return endsAt;
|
|
141
|
+
return new Intl.DateTimeFormat(getActiveLocale(), {
|
|
142
|
+
year: 'numeric',
|
|
143
|
+
month: 'short',
|
|
144
|
+
day: 'numeric',
|
|
145
|
+
timeZone,
|
|
146
|
+
}).format(date);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The same sentences as the hosted portal's `describeFileAccess`. An open file
|
|
151
|
+
* names only the parts the producer sent: no limit and no end date says
|
|
152
|
+
* nothing at all rather than "unlimited".
|
|
153
|
+
*/
|
|
154
|
+
export function describeFileAccess(access: SdkCustomerFileAccess): FileAccessCopy {
|
|
155
|
+
const until = access.ends_at ? fileAccessDate(access.ends_at, access.time_zone) : null;
|
|
156
|
+
switch (access.state) {
|
|
157
|
+
case 'open': {
|
|
158
|
+
const parts: string[] = [];
|
|
159
|
+
if (access.downloads_left !== null) {
|
|
160
|
+
parts.push(t(
|
|
161
|
+
access.downloads_left === 1 ? 'downloads.accessLeftOne' : 'downloads.accessLeftOther',
|
|
162
|
+
{ count: access.downloads_left },
|
|
163
|
+
));
|
|
164
|
+
}
|
|
165
|
+
if (until) parts.push(t('downloads.accessUntil', { date: until }));
|
|
166
|
+
return { text: parts.length > 0 ? parts.join(' · ') : null, refused: false, canAsk: false };
|
|
167
|
+
}
|
|
168
|
+
case 'limit_reached':
|
|
169
|
+
return {
|
|
170
|
+
text: access.download_limit === null
|
|
171
|
+
? t('downloads.accessLimitReached')
|
|
172
|
+
: t(
|
|
173
|
+
access.download_limit === 1 ? 'downloads.accessLimitReachedOne' : 'downloads.accessLimitReachedOther',
|
|
174
|
+
{ count: access.download_limit },
|
|
175
|
+
),
|
|
176
|
+
refused: true,
|
|
177
|
+
canAsk: true,
|
|
178
|
+
};
|
|
179
|
+
case 'expired':
|
|
180
|
+
return {
|
|
181
|
+
text: until ? t('downloads.accessExpired', { date: until }) : t('downloads.accessExpiredUndated'),
|
|
182
|
+
refused: true,
|
|
183
|
+
canAsk: true,
|
|
184
|
+
};
|
|
185
|
+
case 'revoked':
|
|
186
|
+
return { text: t('downloads.accessRevoked'), refused: true, canAsk: true };
|
|
187
|
+
case 'refunded':
|
|
188
|
+
return { text: t('downloads.accessRefunded'), refused: true, canAsk: false };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The ticket "Ask for access" opens: on the order, naming the file and why it
|
|
194
|
+
* stopped, so the shop can extend, reset or restore it from the order page.
|
|
195
|
+
*/
|
|
196
|
+
export function accessRequestTicket(
|
|
197
|
+
file: Pick<SdkCustomerDeliverable, 'label' | 'product_title' | 'invoice_uniqid'>,
|
|
198
|
+
reason: string | null,
|
|
199
|
+
): { title: string; message: string; invoice_id: string } {
|
|
200
|
+
const message = t('downloads.accessRequestMessage', { file: file.label, product: file.product_title });
|
|
201
|
+
return {
|
|
202
|
+
title: t('downloads.accessRequestTitle', { file: file.label }).slice(0, 120),
|
|
203
|
+
message: reason ? `${message} ${t('downloads.accessRequestReason', { reason })}` : message,
|
|
204
|
+
invoice_id: file.invoice_uniqid,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SdkCustomerAffiliateLink, SdkCustomerAffiliateSummary } from '@shoppexio/storefront-react';
|
|
1
|
+
import type { SdkCustomerAffiliateLink, SdkCustomerAffiliateRates, SdkCustomerAffiliateSummary } from '@shoppexio/storefront-react';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The referrals panel's pure half.
|
|
@@ -239,3 +239,72 @@ export function referralLinkInput(draft: ReferralLinkDraft): ReferralLinkInputRe
|
|
|
239
239
|
},
|
|
240
240
|
};
|
|
241
241
|
}
|
|
242
|
+
|
|
243
|
+
/** A catalog key and its values, translated by the panel. */
|
|
244
|
+
export interface ReferralMessage {
|
|
245
|
+
key: string;
|
|
246
|
+
params?: Record<string, string>;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface ReferralRateRow {
|
|
250
|
+
id: 'rate' | 'order' | 'binding';
|
|
251
|
+
title: ReferralMessage;
|
|
252
|
+
detail: ReferralMessage | null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* "Your rates" (Design Batch 3 F2), the same rows as the Easy-lane account
|
|
257
|
+
* block: the rate and the products that earn nothing, the cap and minimum per
|
|
258
|
+
* order, and how long a referred buyer stays. A part shows only when the shop
|
|
259
|
+
* set it. One rate per link was not true once products, a cap and a minimum
|
|
260
|
+
* could pay less.
|
|
261
|
+
*/
|
|
262
|
+
export function referralRateRows(
|
|
263
|
+
rates: SdkCustomerAffiliateRates,
|
|
264
|
+
format: {
|
|
265
|
+
money: (usd: string) => string;
|
|
266
|
+
/** "A, B and C" in the reader's language. */
|
|
267
|
+
list: (names: string[]) => string;
|
|
268
|
+
/** "2 more", for the No Commission products past the three named. */
|
|
269
|
+
more: (count: number) => string;
|
|
270
|
+
},
|
|
271
|
+
): ReferralRateRow[] {
|
|
272
|
+
const percent = String(rates.percent);
|
|
273
|
+
const more = rates.disabled_product_count - rates.disabled_products.length;
|
|
274
|
+
const names = more > 0 ? [...rates.disabled_products, format.more(more)] : rates.disabled_products;
|
|
275
|
+
const disabled: ReferralMessage | null = rates.disabled_product_count === 0
|
|
276
|
+
? null
|
|
277
|
+
: rates.disabled_product_count === 1
|
|
278
|
+
? { key: 'referrals.ratesProductEarnsNothing', params: { product: format.list(names) } }
|
|
279
|
+
: { key: 'referrals.ratesProductsEarnNothing', params: { products: format.list(names) } };
|
|
280
|
+
// A product with its own rate pays that instead of this one, so "every product" would overpromise.
|
|
281
|
+
const customRate: ReferralMessage | null = rates.custom_rate_product_count === 0 ? null : { key: 'referrals.ratesCustomRateProducts' };
|
|
282
|
+
const rows: ReferralRateRow[] = [{
|
|
283
|
+
id: 'rate',
|
|
284
|
+
title: { key: disabled || customRate ? 'referrals.ratesMostProducts' : 'referrals.ratesEveryProduct', params: { percent } },
|
|
285
|
+
detail: disabled ?? customRate,
|
|
286
|
+
}];
|
|
287
|
+
const minimum: ReferralMessage | null = rates.minimum_order_usd === null
|
|
288
|
+
? null
|
|
289
|
+
: { key: 'referrals.ratesMinimum', params: { amount: format.money(rates.minimum_order_usd) } };
|
|
290
|
+
if (rates.cap_usd !== null) {
|
|
291
|
+
rows.push({ id: 'order', title: { key: 'referrals.ratesCap', params: { amount: format.money(rates.cap_usd) } }, detail: minimum });
|
|
292
|
+
} else if (minimum) {
|
|
293
|
+
rows.push({ id: 'order', title: minimum, detail: null });
|
|
294
|
+
}
|
|
295
|
+
if (rates.binding_mode !== 'PER_ORDER') {
|
|
296
|
+
rows.push({
|
|
297
|
+
id: 'binding',
|
|
298
|
+
title: rates.binding_days === null
|
|
299
|
+
? { key: 'referrals.ratesBindingForever' }
|
|
300
|
+
: { key: 'referrals.ratesBindingDays', params: { days: String(rates.binding_days) } },
|
|
301
|
+
detail: { key: 'referrals.ratesBindingDetail' },
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return rows;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** A link names its rate only when it earns another one than "Your rates" says. */
|
|
308
|
+
export function linkCommissionDiffers(link: SdkCustomerAffiliateLink, rates: SdkCustomerAffiliateRates | null): boolean {
|
|
309
|
+
return rates === null || link.effective_commission_percent !== rates.percent;
|
|
310
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure wire narrowing
|
|
2
|
+
* Pure wire narrowing for the wholesale account tab.
|
|
3
3
|
*
|
|
4
4
|
* The shapes below follow the mapper functions in
|
|
5
5
|
* `apps/backend-elysia/src/routes/v1/customer-reseller.ts` exactly. They do not
|
|
@@ -38,6 +38,8 @@ export interface ResellerProgramState {
|
|
|
38
38
|
enrollment_mode: ResellerEnrollmentMode;
|
|
39
39
|
application_message: string | null;
|
|
40
40
|
require_application_note: boolean;
|
|
41
|
+
/** The shop hands out delivery URLs; the "Delivery URL" link shows only then. */
|
|
42
|
+
delivery_url_enabled: boolean;
|
|
41
43
|
reseller: ResellerRelationship | null;
|
|
42
44
|
}
|
|
43
45
|
|
|
@@ -45,7 +47,6 @@ export interface ResellerCatalogVariant {
|
|
|
45
47
|
variant_id: string;
|
|
46
48
|
title: string;
|
|
47
49
|
stock: number;
|
|
48
|
-
supplier_backed: boolean;
|
|
49
50
|
orderable: boolean;
|
|
50
51
|
base_price: string;
|
|
51
52
|
unit_price: string;
|
|
@@ -59,7 +60,6 @@ export interface ResellerCatalogItem {
|
|
|
59
60
|
title: string;
|
|
60
61
|
type: string;
|
|
61
62
|
stock: number;
|
|
62
|
-
supplier_backed: boolean;
|
|
63
63
|
orderable: boolean;
|
|
64
64
|
volume_discount_enabled: boolean;
|
|
65
65
|
base_price: string;
|
|
@@ -82,13 +82,7 @@ export interface ResellerCatalogPayload {
|
|
|
82
82
|
catalog: ResellerCatalog;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
product_id: string;
|
|
87
|
-
variant_id?: string;
|
|
88
|
-
quantity: number;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Producer limits from `ResellerOrderItemSchema`. Quantities are units, not fractions. */
|
|
85
|
+
/** Producer limits from `ResellerOrderItemSchema`. Quantities are units, not fractions; an order line is read against them. */
|
|
92
86
|
export const RESELLER_QUANTITY_MIN_UNITS = 1;
|
|
93
87
|
export const RESELLER_QUANTITY_MAX_UNITS = 100_000;
|
|
94
88
|
|
|
@@ -98,31 +92,6 @@ export function isValidResellerQuantity(value: number): boolean {
|
|
|
98
92
|
&& value <= RESELLER_QUANTITY_MAX_UNITS;
|
|
99
93
|
}
|
|
100
94
|
|
|
101
|
-
export interface ResellerQuoteItem {
|
|
102
|
-
product_id: string;
|
|
103
|
-
product_title: string;
|
|
104
|
-
product_type: string;
|
|
105
|
-
variant_id: string | null;
|
|
106
|
-
variant_title: string | null;
|
|
107
|
-
quantity: number;
|
|
108
|
-
base_price: string;
|
|
109
|
-
discount_percent: number;
|
|
110
|
-
unit_price: string;
|
|
111
|
-
line_total: string;
|
|
112
|
-
applied_override: ResellerAppliedOverride;
|
|
113
|
-
volume_discount_applied: string;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export interface ResellerQuote {
|
|
117
|
-
currency: string;
|
|
118
|
-
tier: { id: string; name: string; discount_percent: number };
|
|
119
|
-
items: ResellerQuoteItem[];
|
|
120
|
-
subtotal: string;
|
|
121
|
-
volume_discount_total: string;
|
|
122
|
-
total: string;
|
|
123
|
-
savings: string;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
95
|
export interface ResellerOrderListItem {
|
|
127
96
|
uniqid: string;
|
|
128
97
|
status: string;
|
|
@@ -287,6 +256,7 @@ export function narrowResellerProgram(
|
|
|
287
256
|
!isEnrollmentMode(payload.enrollment_mode) ||
|
|
288
257
|
!isNullableString(payload.application_message) ||
|
|
289
258
|
typeof payload.require_application_note !== "boolean" ||
|
|
259
|
+
typeof payload.delivery_url_enabled !== "boolean" ||
|
|
290
260
|
reseller === undefined
|
|
291
261
|
) {
|
|
292
262
|
return { ok: false };
|
|
@@ -298,6 +268,7 @@ export function narrowResellerProgram(
|
|
|
298
268
|
enrollment_mode: payload.enrollment_mode,
|
|
299
269
|
application_message: payload.application_message,
|
|
300
270
|
require_application_note: payload.require_application_note,
|
|
271
|
+
delivery_url_enabled: payload.delivery_url_enabled,
|
|
301
272
|
reseller,
|
|
302
273
|
},
|
|
303
274
|
};
|
|
@@ -318,7 +289,6 @@ function narrowCatalogVariant(value: unknown): ResellerCatalogVariant | null {
|
|
|
318
289
|
typeof value.variant_id !== "string" ||
|
|
319
290
|
typeof value.title !== "string" ||
|
|
320
291
|
!isFiniteNumber(value.stock) ||
|
|
321
|
-
typeof value.supplier_backed !== "boolean" ||
|
|
322
292
|
typeof value.orderable !== "boolean" ||
|
|
323
293
|
typeof value.base_price !== "string" ||
|
|
324
294
|
typeof value.unit_price !== "string" ||
|
|
@@ -343,7 +313,6 @@ function narrowCatalogItem(value: unknown): ResellerCatalogItem | null {
|
|
|
343
313
|
typeof value.title !== "string" ||
|
|
344
314
|
typeof value.type !== "string" ||
|
|
345
315
|
!isFiniteNumber(value.stock) ||
|
|
346
|
-
typeof value.supplier_backed !== "boolean" ||
|
|
347
316
|
typeof value.orderable !== "boolean" ||
|
|
348
317
|
typeof value.volume_discount_enabled !== "boolean" ||
|
|
349
318
|
typeof value.base_price !== "string" ||
|
|
@@ -358,7 +327,6 @@ function narrowCatalogItem(value: unknown): ResellerCatalogItem | null {
|
|
|
358
327
|
title: value.title,
|
|
359
328
|
type: value.type,
|
|
360
329
|
stock: value.stock,
|
|
361
|
-
supplier_backed: value.supplier_backed,
|
|
362
330
|
orderable: value.orderable,
|
|
363
331
|
volume_discount_enabled: value.volume_discount_enabled,
|
|
364
332
|
base_price: value.base_price,
|
|
@@ -412,69 +380,6 @@ export function narrowResellerCatalog(
|
|
|
412
380
|
};
|
|
413
381
|
}
|
|
414
382
|
|
|
415
|
-
function narrowQuoteItem(value: unknown): ResellerQuoteItem | null {
|
|
416
|
-
if (!isRecord(value)) return null;
|
|
417
|
-
if (
|
|
418
|
-
typeof value.product_id !== "string" ||
|
|
419
|
-
typeof value.product_title !== "string" ||
|
|
420
|
-
typeof value.product_type !== "string" ||
|
|
421
|
-
!isNullableString(value.variant_id) ||
|
|
422
|
-
!isNullableString(value.variant_title) ||
|
|
423
|
-
!isValidResellerQuantity(value.quantity as number) ||
|
|
424
|
-
typeof value.base_price !== "string" ||
|
|
425
|
-
!isFiniteNumber(value.discount_percent) ||
|
|
426
|
-
typeof value.unit_price !== "string" ||
|
|
427
|
-
typeof value.line_total !== "string" ||
|
|
428
|
-
!isAppliedOverride(value.applied_override) ||
|
|
429
|
-
typeof value.volume_discount_applied !== "string"
|
|
430
|
-
)
|
|
431
|
-
return null;
|
|
432
|
-
return value as unknown as ResellerQuoteItem;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/** `POST /reseller/orders/quote`, written against mapQuote. */
|
|
436
|
-
export function narrowResellerQuote(
|
|
437
|
-
payload: unknown,
|
|
438
|
-
): NarrowResult<ResellerQuote> {
|
|
439
|
-
if (!isRecord(payload) || !isRecord(payload.quote)) return { ok: false };
|
|
440
|
-
const raw = payload.quote;
|
|
441
|
-
const tier = narrowTier(raw.tier);
|
|
442
|
-
if (
|
|
443
|
-
!tier ||
|
|
444
|
-
typeof tier.name !== "string" ||
|
|
445
|
-
typeof tier.discount_percent !== "number" ||
|
|
446
|
-
typeof raw.currency !== "string" ||
|
|
447
|
-
!Array.isArray(raw.items) ||
|
|
448
|
-
typeof raw.subtotal !== "string" ||
|
|
449
|
-
typeof raw.volume_discount_total !== "string" ||
|
|
450
|
-
typeof raw.total !== "string" ||
|
|
451
|
-
typeof raw.savings !== "string"
|
|
452
|
-
)
|
|
453
|
-
return { ok: false };
|
|
454
|
-
const items: ResellerQuoteItem[] = [];
|
|
455
|
-
for (const entry of raw.items) {
|
|
456
|
-
const item = narrowQuoteItem(entry);
|
|
457
|
-
if (!item) return { ok: false };
|
|
458
|
-
items.push(item);
|
|
459
|
-
}
|
|
460
|
-
return {
|
|
461
|
-
ok: true,
|
|
462
|
-
data: {
|
|
463
|
-
currency: raw.currency,
|
|
464
|
-
tier: {
|
|
465
|
-
id: tier.id,
|
|
466
|
-
name: tier.name,
|
|
467
|
-
discount_percent: tier.discount_percent,
|
|
468
|
-
},
|
|
469
|
-
items,
|
|
470
|
-
subtotal: raw.subtotal,
|
|
471
|
-
volume_discount_total: raw.volume_discount_total,
|
|
472
|
-
total: raw.total,
|
|
473
|
-
savings: raw.savings,
|
|
474
|
-
},
|
|
475
|
-
};
|
|
476
|
-
}
|
|
477
|
-
|
|
478
383
|
function narrowOrderListItem(value: unknown): ResellerOrderListItem | null {
|
|
479
384
|
if (!isRecord(value)) return null;
|
|
480
385
|
if (
|
|
@@ -627,35 +532,6 @@ export function narrowResellerApiKeys(
|
|
|
627
532
|
return { ok: true, data: keys };
|
|
628
533
|
}
|
|
629
534
|
|
|
630
|
-
/** Stable identity of one basket version. Order is normalized for comparison. */
|
|
631
|
-
export function resellerBasketKey(
|
|
632
|
-
items: readonly ResellerOrderItemInput[],
|
|
633
|
-
): string {
|
|
634
|
-
for (const item of items) {
|
|
635
|
-
if (!isValidResellerQuantity(item.quantity)) {
|
|
636
|
-
throw new RangeError(
|
|
637
|
-
`Wholesale quantity must be a whole number from ${RESELLER_QUANTITY_MIN_UNITS} to ${RESELLER_QUANTITY_MAX_UNITS} units; received ${String(item.quantity)}.`,
|
|
638
|
-
);
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
return JSON.stringify(
|
|
643
|
-
[...items]
|
|
644
|
-
.map((item) => ({
|
|
645
|
-
product_id: item.product_id,
|
|
646
|
-
variant_id: item.variant_id ?? null,
|
|
647
|
-
quantity: item.quantity,
|
|
648
|
-
}))
|
|
649
|
-
.sort((left, right) => {
|
|
650
|
-
const product = left.product_id.localeCompare(right.product_id);
|
|
651
|
-
return (
|
|
652
|
-
product ||
|
|
653
|
-
(left.variant_id ?? "").localeCompare(right.variant_id ?? "")
|
|
654
|
-
);
|
|
655
|
-
}),
|
|
656
|
-
);
|
|
657
|
-
}
|
|
658
|
-
|
|
659
535
|
/**
|
|
660
536
|
* The one way into the program this buyer actually has.
|
|
661
537
|
*
|
|
@@ -673,13 +549,3 @@ export function resellerEntryMode(
|
|
|
673
549
|
? "MANUAL"
|
|
674
550
|
: program.enrollment_mode;
|
|
675
551
|
}
|
|
676
|
-
|
|
677
|
-
/** A quote is displayed only for the basket it priced. */
|
|
678
|
-
export function quoteMatchesBasket(
|
|
679
|
-
quotedBasketKey: string | null,
|
|
680
|
-
items: readonly ResellerOrderItemInput[],
|
|
681
|
-
): boolean {
|
|
682
|
-
return (
|
|
683
|
-
quotedBasketKey !== null && quotedBasketKey === resellerBasketKey(items)
|
|
684
|
-
);
|
|
685
|
-
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SdkCustomerSubscription } from '@shoppexio/storefront-react';
|
|
1
|
+
import type { SdkCustomerOpenRenewal, SdkCustomerPauseOptions, SdkCustomerSubscription } from '@shoppexio/storefront-react';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The subscriptions panel's pure half: which date matters, how the plan bills,
|
|
@@ -14,17 +14,38 @@ import type { SdkCustomerSubscription } from '@shoppexio/storefront-react';
|
|
|
14
14
|
* renewal it will never take.
|
|
15
15
|
*/
|
|
16
16
|
export type SubscriptionTiming =
|
|
17
|
+
| { kind: 'ended'; date: string }
|
|
17
18
|
| { kind: 'canceled'; date: string }
|
|
18
19
|
| { kind: 'ends'; date: string }
|
|
20
|
+
/** A renewal on hold or past its date: the deadline to pay it, not a renewal date already gone. */
|
|
21
|
+
| { kind: 'payBy'; date: string }
|
|
22
|
+
| { kind: 'pauses'; date: string }
|
|
23
|
+
| { kind: 'resumes'; date: string }
|
|
24
|
+
/** Paused by the merchant or the API without a resume date. */
|
|
25
|
+
| { kind: 'pausedOpenEnded'; date: string }
|
|
19
26
|
| { kind: 'trial'; date: string }
|
|
20
27
|
| { kind: 'renews'; date: string };
|
|
21
28
|
|
|
22
29
|
export function subscriptionTiming(subscription: SdkCustomerSubscription): SubscriptionTiming {
|
|
30
|
+
if (endedAfterHold(subscription) && subscription.canceled_at) {
|
|
31
|
+
return { kind: 'ended', date: subscription.canceled_at };
|
|
32
|
+
}
|
|
23
33
|
if (subscription.status.toUpperCase() === 'CANCELED' && subscription.canceled_at) {
|
|
24
34
|
return { kind: 'canceled', date: subscription.canceled_at };
|
|
25
35
|
}
|
|
26
|
-
|
|
27
|
-
|
|
36
|
+
const renewal = openRenewalState(subscription);
|
|
37
|
+
if (subscription.open_renewal && (renewal === 'held' || renewal === 'due')) {
|
|
38
|
+
return { kind: 'payBy', date: subscription.open_renewal.pay_by };
|
|
39
|
+
}
|
|
40
|
+
// The producer's one "set to end" fact: cancelled at the period end, or a voided renewal.
|
|
41
|
+
if (subscription.ends_at) {
|
|
42
|
+
return { kind: 'ends', date: subscription.ends_at };
|
|
43
|
+
}
|
|
44
|
+
// A paused or pausing plan bills nothing at its period end; it bills again when it resumes.
|
|
45
|
+
const pause = subscription.pause;
|
|
46
|
+
if (pause?.state === 'scheduled') return { kind: 'pauses', date: pause.starts_at };
|
|
47
|
+
if (pause?.state === 'paused') {
|
|
48
|
+
return pause.resume_at === null ? { kind: 'pausedOpenEnded', date: '' } : { kind: 'resumes', date: pause.resume_at };
|
|
28
49
|
}
|
|
29
50
|
if (subscription.status.toUpperCase() === 'TRIALING' && subscription.trial_end) {
|
|
30
51
|
return { kind: 'trial', date: subscription.trial_end };
|
|
@@ -34,7 +55,24 @@ export function subscriptionTiming(subscription: SdkCustomerSubscription): Subsc
|
|
|
34
55
|
|
|
35
56
|
/** The catalog key for a timing, so the panel never spells one itself. */
|
|
36
57
|
export function subscriptionTimingKey(timing: SubscriptionTiming): string {
|
|
37
|
-
return `subscriptions.${timing.kind}`;
|
|
58
|
+
return timing.kind === 'ended' ? 'subscriptions.endedOn' : `subscriptions.${timing.kind}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A subscription that ended with its renewal still unpaid (the hold ran out, or the
|
|
63
|
+
* shop ended it during the hold): CANCELED with the `hold_ends_at` the hold left on
|
|
64
|
+
* the row. `ranOut` is read from the two producer timestamps, never from the date today.
|
|
65
|
+
*/
|
|
66
|
+
export function endedAfterHold(subscription: SdkCustomerSubscription): { ranOut: boolean } | null {
|
|
67
|
+
if (subscription.status.toUpperCase() !== 'CANCELED' || !subscription.hold_ends_at || !subscription.canceled_at) return null;
|
|
68
|
+
const canceledAt = producerTimestamp(subscription.canceled_at);
|
|
69
|
+
const holdEndsAt = producerTimestamp(subscription.hold_ends_at);
|
|
70
|
+
return { ranOut: canceledAt !== null && holdEndsAt !== null && canceledAt >= holdEndsAt };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The catalog key of the ended note's sentence. */
|
|
74
|
+
export function endedNoteKey(ended: { ranOut: boolean }): string {
|
|
75
|
+
return ended.ranOut ? 'subscriptions.endedHoldRanOut' : 'subscriptions.endedShopEnded';
|
|
38
76
|
}
|
|
39
77
|
|
|
40
78
|
const KNOWN_INTERVALS: ReadonlySet<string> = new Set(['day', 'week', 'month', 'year']);
|
|
@@ -82,6 +120,44 @@ export interface BillingHistoryInvoice {
|
|
|
82
120
|
currency: string;
|
|
83
121
|
status: string;
|
|
84
122
|
created_at: string;
|
|
123
|
+
/** What the invoice billed: the order that started the plan, a renewal, or a plan change. */
|
|
124
|
+
kind: 'first_order' | 'renewal' | 'plan_change';
|
|
125
|
+
/** The plan change it paid for; null on a renewal or a first order. Days are null when the producer did not count them. */
|
|
126
|
+
plan_change: { from_plan_name: string; to_plan_name: string; days_left: number | null; days_total: number | null } | null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const BILLING_KINDS: ReadonlyArray<BillingHistoryInvoice['kind']> = ['first_order', 'renewal', 'plan_change'];
|
|
130
|
+
|
|
131
|
+
function readCount(record: Record<string, unknown>, key: string): number | null | undefined {
|
|
132
|
+
const value = record[key];
|
|
133
|
+
if (value === null) return null;
|
|
134
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readPlanChange(value: unknown): BillingHistoryInvoice['plan_change'] | undefined {
|
|
138
|
+
if (value === null) return null;
|
|
139
|
+
if (!isRecord(value)) return undefined;
|
|
140
|
+
const from = readString(value, 'from_plan_name');
|
|
141
|
+
const to = readString(value, 'to_plan_name');
|
|
142
|
+
const daysLeft = readCount(value, 'days_left');
|
|
143
|
+
const daysTotal = readCount(value, 'days_total');
|
|
144
|
+
if (from === null || to === null || daysLeft === undefined || daysTotal === undefined) return undefined;
|
|
145
|
+
return { from_plan_name: from, to_plan_name: to, days_left: daysLeft, days_total: daysTotal };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** What a billing history row billed (Design Batch 9, C3), as a catalog key: "Renewal", "First order", "Plan change, Basic to Pro". */
|
|
149
|
+
export function billingEntryTitle(invoice: BillingHistoryInvoice): { key: string; params?: { from: string; to: string } } {
|
|
150
|
+
if (invoice.plan_change) {
|
|
151
|
+
return { key: 'subscriptions.historyPlanChange', params: { from: invoice.plan_change.from_plan_name, to: invoice.plan_change.to_plan_name } };
|
|
152
|
+
}
|
|
153
|
+
return { key: invoice.kind === 'first_order' ? 'subscriptions.historyFirstOrder' : 'subscriptions.historyRenewal' };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** "for 12 of 30 days" beside a plan change's date, or null when there is nothing to count. */
|
|
157
|
+
export function billingEntryDays(invoice: BillingHistoryInvoice): { key: string; params: { days: number; total: number } } | null {
|
|
158
|
+
const change = invoice.plan_change;
|
|
159
|
+
if (!change || change.days_left === null || change.days_total === null) return null;
|
|
160
|
+
return { key: 'subscriptions.historyDays', params: { days: change.days_left, total: change.days_total } };
|
|
85
161
|
}
|
|
86
162
|
|
|
87
163
|
export type BillingHistoryResult =
|
|
@@ -118,13 +194,89 @@ export function narrowBillingHistory(payload: unknown): BillingHistoryResult {
|
|
|
118
194
|
const currency = readString(entry, 'currency');
|
|
119
195
|
const status = readString(entry, 'status');
|
|
120
196
|
const createdAt = readString(entry, 'created_at');
|
|
121
|
-
|
|
197
|
+
// A kind this build does not know fails the read: a row that guesses what it billed could misname a charge.
|
|
198
|
+
const kind = BILLING_KINDS.find((known) => known === entry.kind);
|
|
199
|
+
const planChange = readPlanChange(entry.plan_change);
|
|
200
|
+
if (uniqid === null || total === null || currency === null || status === null || createdAt === null || !kind || planChange === undefined) {
|
|
122
201
|
return { ok: false };
|
|
123
202
|
}
|
|
124
203
|
|
|
125
|
-
invoices.push({ uniqid, total, currency, status, created_at: createdAt });
|
|
204
|
+
invoices.push({ uniqid, total, currency, status, created_at: createdAt, kind, plan_change: planChange });
|
|
126
205
|
}
|
|
127
206
|
|
|
128
207
|
return { ok: true, invoices };
|
|
129
208
|
}
|
|
130
209
|
|
|
210
|
+
/** The length the pause dialog opens with: one month when the shop offers it, else the longest it offers. */
|
|
211
|
+
export function defaultPauseLength(options: SdkCustomerPauseOptions): SdkCustomerPauseOptions['lengths'][number]['days'] | null {
|
|
212
|
+
if (options.lengths.some((length) => length.days === 30)) return 30;
|
|
213
|
+
return options.lengths[options.lengths.length - 1]?.days ?? null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** "You can pause {n} more times…" as a catalog key and its count. */
|
|
217
|
+
export function pausesLeftMessage(left: number): { key: string; params: { count: number } } {
|
|
218
|
+
const key = left <= 0 ? 'subscriptions.pauseLeftNone' : left === 1 ? 'subscriptions.pauseLeftOne' : 'subscriptions.pauseLeftMany';
|
|
219
|
+
return { key, params: { count: left } };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Whether "Resume now" bills a new period today (`resumedPeriodEnd` in subscriptionPause.ts):
|
|
224
|
+
* a pause from the period end left no paid days, a "right away" pause gives the days left back.
|
|
225
|
+
*/
|
|
226
|
+
export function resumeBillsToday(subscription: SdkCustomerSubscription): boolean {
|
|
227
|
+
const pause = subscription.pause;
|
|
228
|
+
if (pause?.state !== 'paused') return false;
|
|
229
|
+
const periodEnd = producerTimestamp(subscription.current_period_end);
|
|
230
|
+
const startedAt = producerTimestamp(pause.starts_at);
|
|
231
|
+
return periodEnd !== null && startedAt !== null && periodEnd <= startedAt;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Where the open renewal stands, read only from the producer's `open_renewal`
|
|
236
|
+
* and `hold_ends_at`, never from guessed dates:
|
|
237
|
+
* - `held`: on hold (PAST_DUE with a hold end), access is off until the buyer pays;
|
|
238
|
+
* - `ready`: invoiced ahead, the renewal date is still to come;
|
|
239
|
+
* - `due`: the renewal date passed and the time to pay runs.
|
|
240
|
+
* Null when nothing is open, and on an ended subscription.
|
|
241
|
+
*/
|
|
242
|
+
export type OpenRenewalState = 'held' | 'due' | 'ready';
|
|
243
|
+
|
|
244
|
+
const NOTE_INTERVALS: Readonly<Record<string, string>> = { DAY: 'Day', WEEK: 'Week', MONTH: 'Month', YEAR: 'Year' };
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The unit inside a renewal note ("a new month"), from the plan the renewal invoice bills
|
|
248
|
+
* (a scheduled interval change bills its target before the subscription's own plan moves):
|
|
249
|
+
* only a plan that bills once per unit names it. Several units or an unknown unit read as
|
|
250
|
+
* the generic billing period, never as a unit the producer did not send.
|
|
251
|
+
*/
|
|
252
|
+
export function noteIntervalKey(open: SdkCustomerOpenRenewal, note: 'hold' | 'ready'): string {
|
|
253
|
+
const unit = open.interval_count === 1 ? NOTE_INTERVALS[open.interval.trim().toUpperCase()] : undefined;
|
|
254
|
+
return `subscriptions.${note}Interval${unit ?? 'Other'}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function openRenewalState(subscription: SdkCustomerSubscription, now: number = Date.now()): OpenRenewalState | null {
|
|
258
|
+
const status = subscription.status.toUpperCase();
|
|
259
|
+
if (!subscription.open_renewal || status === 'CANCELED') return null;
|
|
260
|
+
if (status === 'PAST_DUE' && subscription.hold_ends_at) return 'held';
|
|
261
|
+
const renewsAt = producerTimestamp(subscription.current_period_end);
|
|
262
|
+
return renewsAt !== null && renewsAt > now ? 'ready' : 'due';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The renewal date for display: naive PostgreSQL strings ("2026-08-10 00:00:00.000")
|
|
267
|
+
* are UTC, not the buyer's local zone. An unreadable value comes back as it came.
|
|
268
|
+
*/
|
|
269
|
+
export function renewalDate(value: string): Date | string {
|
|
270
|
+
const parsed = producerTimestamp(value);
|
|
271
|
+
return parsed === null ? value : new Date(parsed);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function producerTimestamp(value: string): number | null {
|
|
275
|
+
const trimmed = value.trim();
|
|
276
|
+
if (!trimmed) return null;
|
|
277
|
+
const hasTimezone = /(?:Z|[+-]\d{2}(?::?\d{2})?)$/i.test(trimmed);
|
|
278
|
+
const hasTimePart = trimmed.includes(' ') || trimmed.includes('T');
|
|
279
|
+
const normalized = hasTimezone || !hasTimePart ? trimmed : `${trimmed.replace(' ', 'T')}Z`;
|
|
280
|
+
const parsed = Date.parse(normalized);
|
|
281
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
282
|
+
}
|