@shoppexio/storefront-react 0.3.0 → 0.4.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 +23 -0
- package/dist/account.css +4 -8
- package/dist/account.js +1257 -778
- package/dist/account.js.map +1 -1
- package/dist/index.d.ts +92 -8
- package/dist/index.js +15 -16
- package/dist/index.js.map +1 -1
- package/eject/account/account-downloads.ts +89 -1
- package/eject/account/account-referrals.ts +70 -1
- package/eject/account/account-reseller.ts +6 -140
- package/eject/account/downloads.tsx +73 -4
- package/eject/account/hosted-page.ts +45 -0
- package/eject/account/messages/ar.json +55 -29
- package/eject/account/messages/de.json +55 -29
- package/eject/account/messages/en.json +55 -29
- package/eject/account/messages/es.json +55 -29
- package/eject/account/messages/fi.json +55 -29
- package/eject/account/messages/fr.json +55 -29
- package/eject/account/messages/hi.json +55 -29
- package/eject/account/messages/it.json +55 -29
- package/eject/account/messages/lt.json +55 -29
- package/eject/account/messages/nl.json +55 -29
- package/eject/account/messages/pl.json +55 -29
- package/eject/account/messages/pt.json +55 -29
- package/eject/account/messages/ru.json +55 -29
- package/eject/account/messages/tr.json +55 -29
- package/eject/account/messages/uk.json +55 -29
- package/eject/account/messages/zh.json +55 -29
- package/eject/account/orders.tsx +88 -0
- package/eject/account/overview.tsx +6 -0
- package/eject/account/referrals.tsx +47 -5
- package/eject/account/reseller.tsx +59 -306
- package/eject/account/rewards.tsx +5 -0
- package/eject/account/shared.tsx +39 -1
- package/eject/account/subscriptions.tsx +75 -16
- 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
|
-
}
|
|
@@ -19,7 +19,9 @@ import { useState } from 'react';
|
|
|
19
19
|
import { Link } from 'react-router-dom';
|
|
20
20
|
import { accountOrderHref } from '@shoppexio/storefront-react';
|
|
21
21
|
import {
|
|
22
|
+
accessRequestTicket,
|
|
22
23
|
buildDownloadGroups,
|
|
24
|
+
describeFileAccess,
|
|
23
25
|
isEmptyDownloadGroups,
|
|
24
26
|
licenseExpiryDate,
|
|
25
27
|
offersRenewal,
|
|
@@ -50,6 +52,8 @@ export function AccountDownloads() {
|
|
|
50
52
|
const [notice, setNotice] = useState<string | null>(null);
|
|
51
53
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
52
54
|
const [resettingUniqid, setResettingUniqid] = useState<string | null>(null);
|
|
55
|
+
const [askingId, setAskingId] = useState<string | null>(null);
|
|
56
|
+
const [askedIds, setAskedIds] = useState<ReadonlySet<string>>(() => new Set());
|
|
53
57
|
|
|
54
58
|
if (state.status === 'loading') {
|
|
55
59
|
return <AccountRowsSkeleton rows={4} label={t('downloads.loading')} />;
|
|
@@ -108,6 +112,29 @@ export function AccountDownloads() {
|
|
|
108
112
|
reload();
|
|
109
113
|
};
|
|
110
114
|
|
|
115
|
+
/*
|
|
116
|
+
* "Ask for access" is a support ticket on the order, through the same
|
|
117
|
+
* `createTicket` the Support panel uses, so the shop answers it where it
|
|
118
|
+
* answers everything else. One request in flight; a row that asked once says
|
|
119
|
+
* so instead of offering a second ticket.
|
|
120
|
+
*/
|
|
121
|
+
const askForAccess = async (file: FileDownload, reason: string | null) => {
|
|
122
|
+
if (askingId !== null) return;
|
|
123
|
+
setAskingId(file.id);
|
|
124
|
+
setNotice(null);
|
|
125
|
+
setActionError(null);
|
|
126
|
+
const result = await callCustomerApi((sdk) => sdk.createTicket(accessRequestTicket(file, reason)));
|
|
127
|
+
setAskingId(null);
|
|
128
|
+
|
|
129
|
+
if (!result.success) {
|
|
130
|
+
setActionError(result.message?.trim() || t('downloads.accessRequestFailed'));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
setAskedIds((current) => new Set(current).add(file.id));
|
|
135
|
+
setNotice(t('downloads.accessRequestDone'));
|
|
136
|
+
};
|
|
137
|
+
|
|
111
138
|
return (
|
|
112
139
|
<div className="sxa:space-y-8">
|
|
113
140
|
{notice ? <AccountNotice message={notice} /> : null}
|
|
@@ -137,7 +164,13 @@ export function AccountDownloads() {
|
|
|
137
164
|
<ul className="sxa:mt-4 sxa:space-y-3">
|
|
138
165
|
{groups.files.map((file) => (
|
|
139
166
|
<li key={file.id}>
|
|
140
|
-
<FileRow
|
|
167
|
+
<FileRow
|
|
168
|
+
file={file}
|
|
169
|
+
asking={askingId === file.id}
|
|
170
|
+
asked={askedIds.has(file.id)}
|
|
171
|
+
disabled={askingId !== null}
|
|
172
|
+
onAskForAccess={(reason) => void askForAccess(file, reason)}
|
|
173
|
+
/>
|
|
141
174
|
</li>
|
|
142
175
|
))}
|
|
143
176
|
</ul>
|
|
@@ -267,7 +300,19 @@ function LicenseRow({
|
|
|
267
300
|
);
|
|
268
301
|
}
|
|
269
302
|
|
|
270
|
-
function FileRow({
|
|
303
|
+
function FileRow({
|
|
304
|
+
file,
|
|
305
|
+
asking,
|
|
306
|
+
asked,
|
|
307
|
+
disabled,
|
|
308
|
+
onAskForAccess,
|
|
309
|
+
}: {
|
|
310
|
+
file: FileDownload;
|
|
311
|
+
asking: boolean;
|
|
312
|
+
asked: boolean;
|
|
313
|
+
disabled: boolean;
|
|
314
|
+
onAskForAccess: (reason: string | null) => void;
|
|
315
|
+
}) {
|
|
271
316
|
/*
|
|
272
317
|
* A plain link, not a fetch. The path is same-origin under `/api/customer/`,
|
|
273
318
|
* so the browser sends the session cookie the Shoppex worker owns — there is
|
|
@@ -279,21 +324,45 @@ function FileRow({ file }: { file: FileDownload }) {
|
|
|
279
324
|
* because a dead link dressed as a button is worse than a plain refusal.
|
|
280
325
|
*/
|
|
281
326
|
const href = safeExternalHref(file.href);
|
|
327
|
+
/*
|
|
328
|
+
* What is left and until when, or why the file closed. A file the server
|
|
329
|
+
* refuses offers no Download: the link would only answer with that refusal.
|
|
330
|
+
* A missing `access` (older backend) is a file with nothing to say.
|
|
331
|
+
*/
|
|
332
|
+
const access = file.access ? describeFileAccess(file.access) : null;
|
|
282
333
|
|
|
283
334
|
return (
|
|
284
335
|
<AccountCard className="sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-4 sxa:px-5 sxa:py-4">
|
|
285
336
|
<BoxIcon className="sxa:h-4 sxa:w-4 sxa:shrink-0 sxa:text-muted" />
|
|
286
337
|
<div className="sxa:min-w-0 sxa:flex-1">
|
|
287
338
|
<p className="sxa:truncate sxa:text-small sxa:font-semibold">{file.label}</p>
|
|
288
|
-
<p className="sxa:mt-1 sxa:
|
|
339
|
+
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted" data-file-access={file.access?.state}>
|
|
340
|
+
{[file.product_title, access?.text].filter(Boolean).join(' · ')}
|
|
341
|
+
</p>
|
|
289
342
|
</div>
|
|
290
|
-
{href === null ? (
|
|
343
|
+
{access?.refused ? null : href === null ? (
|
|
291
344
|
<span className="sx-account-chip sxa:meta-chip sxa:meta-chip--neutral">{t('common.unavailable')}</span>
|
|
292
345
|
) : (
|
|
293
346
|
<a href={href} download className="sx-account-button sxa:card-button sxa:card-button--add sxa:shrink-0">
|
|
294
347
|
{t('downloads.download')}
|
|
295
348
|
</a>
|
|
296
349
|
)}
|
|
350
|
+
{access?.canAsk ? (
|
|
351
|
+
asked ? (
|
|
352
|
+
<p role="status" className="sxa:shrink-0 sxa:text-micro sxa:text-muted">{t('downloads.accessRequested')}</p>
|
|
353
|
+
) : (
|
|
354
|
+
<button
|
|
355
|
+
type="button"
|
|
356
|
+
onClick={() => onAskForAccess(access.text)}
|
|
357
|
+
disabled={disabled}
|
|
358
|
+
aria-busy={asking || undefined}
|
|
359
|
+
className="sx-account-button sxa:card-button sxa:card-button--add sxa:shrink-0 sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
|
|
360
|
+
>
|
|
361
|
+
{asking ? <BusySpinner className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" /> : null}
|
|
362
|
+
{asking ? t('downloads.askingAccess') : t('downloads.askAccess')}
|
|
363
|
+
</button>
|
|
364
|
+
)
|
|
365
|
+
) : null}
|
|
297
366
|
</AccountCard>
|
|
298
367
|
);
|
|
299
368
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The way out of the account to a hosted buyer page on the checkout host
|
|
3
|
+
* (design Batch 7, H2 and A1): `{checkout}/{page}/{shopId}`.
|
|
4
|
+
*
|
|
5
|
+
* Adding funds, changing a card and handing out a reseller key happen there,
|
|
6
|
+
* behind the page's own email code, because the JavaScript on a storefront
|
|
7
|
+
* origin may be the merchant's. So the link carries only the shop, the page to
|
|
8
|
+
* come back to and the page's display hints; never an email or a token. The
|
|
9
|
+
* hosted page reads and checks everything again on the server, so a forged
|
|
10
|
+
* hint can at most open the wrong segment.
|
|
11
|
+
*
|
|
12
|
+
* The same rule as `hostedPageUrl` in
|
|
13
|
+
* `packages/storefront-commerce/src/sx/hosted-page-url.ts`, which the theme
|
|
14
|
+
* lane uses. This package does not depend on storefront-commerce, so it keeps
|
|
15
|
+
* its own copy; the two must build the same URL.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type HostedPage = 'topup' | 'reseller' | 'billing';
|
|
19
|
+
|
|
20
|
+
/** `return` is the account page the buyer comes back to; every other entry is a display hint. */
|
|
21
|
+
export type HostedPageQuery = { return: string } & Record<string, string>;
|
|
22
|
+
|
|
23
|
+
export function hostedPageUrl(
|
|
24
|
+
checkoutBaseUrl: string,
|
|
25
|
+
page: HostedPage,
|
|
26
|
+
shopId: string,
|
|
27
|
+
query: HostedPageQuery,
|
|
28
|
+
): string {
|
|
29
|
+
const url = new URL(`/${page}/${encodeURIComponent(shopId)}`, checkoutBaseUrl);
|
|
30
|
+
for (const [name, value] of Object.entries(query)) url.searchParams.set(name, value);
|
|
31
|
+
return url.toString();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The link to `page` for the shop this storefront serves, back to the page the
|
|
36
|
+
* buyer is on now. Null when the worker injected no `shopId` or no
|
|
37
|
+
* `checkoutBaseUrl`: without both there is no page to link to, and the caller
|
|
38
|
+
* renders nothing rather than a guessed host.
|
|
39
|
+
*/
|
|
40
|
+
export function hostedPageHref(page: HostedPage, hints: Record<string, string>): string | null {
|
|
41
|
+
const shopId = typeof window.shopId === 'string' ? window.shopId.trim() : '';
|
|
42
|
+
const checkoutBaseUrl = typeof window.checkoutBaseUrl === 'string' ? window.checkoutBaseUrl.trim() : '';
|
|
43
|
+
if (!shopId || !checkoutBaseUrl) return null;
|
|
44
|
+
return hostedPageUrl(checkoutBaseUrl, page, shopId, { ...hints, return: window.location.href });
|
|
45
|
+
}
|