@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/account.css +4 -8
  3. package/dist/account.js +1257 -778
  4. package/dist/account.js.map +1 -1
  5. package/dist/index.d.ts +92 -8
  6. package/dist/index.js +15 -16
  7. package/dist/index.js.map +1 -1
  8. package/eject/account/account-downloads.ts +89 -1
  9. package/eject/account/account-referrals.ts +70 -1
  10. package/eject/account/account-reseller.ts +6 -140
  11. package/eject/account/downloads.tsx +73 -4
  12. package/eject/account/hosted-page.ts +45 -0
  13. package/eject/account/messages/ar.json +55 -29
  14. package/eject/account/messages/de.json +55 -29
  15. package/eject/account/messages/en.json +55 -29
  16. package/eject/account/messages/es.json +55 -29
  17. package/eject/account/messages/fi.json +55 -29
  18. package/eject/account/messages/fr.json +55 -29
  19. package/eject/account/messages/hi.json +55 -29
  20. package/eject/account/messages/it.json +55 -29
  21. package/eject/account/messages/lt.json +55 -29
  22. package/eject/account/messages/nl.json +55 -29
  23. package/eject/account/messages/pl.json +55 -29
  24. package/eject/account/messages/pt.json +55 -29
  25. package/eject/account/messages/ru.json +55 -29
  26. package/eject/account/messages/tr.json +55 -29
  27. package/eject/account/messages/uk.json +55 -29
  28. package/eject/account/messages/zh.json +55 -29
  29. package/eject/account/orders.tsx +88 -0
  30. package/eject/account/overview.tsx +6 -0
  31. package/eject/account/referrals.tsx +47 -5
  32. package/eject/account/reseller.tsx +59 -306
  33. package/eject/account/rewards.tsx +5 -0
  34. package/eject/account/shared.tsx +39 -1
  35. package/eject/account/subscriptions.tsx +75 -16
  36. 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 and basket quote helpers for the wholesale account tab.
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
- export interface ResellerOrderItemInput {
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 file={file} />
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({ file }: { file: FileDownload }) {
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:truncate sxa:text-micro sxa:text-muted">{file.product_title}</p>
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
+ }