@shoppexio/storefront-react 0.0.0 → 0.1.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/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +215 -0
- package/dist/account.css +1171 -0
- package/dist/account.d.ts +23 -0
- package/dist/account.js +12685 -0
- package/dist/account.js.map +1 -0
- package/dist/chunk-D6XKDVAV.js +181 -0
- package/dist/chunk-D6XKDVAV.js.map +1 -0
- package/dist/index.d.ts +3087 -0
- package/dist/index.js +2801 -0
- package/dist/index.js.map +1 -0
- package/eject/account/account-availability.ts +171 -0
- package/eject/account/account-downloads.ts +118 -0
- package/eject/account/account-favorites.ts +99 -0
- package/eject/account/account-loyalty.ts +241 -0
- package/eject/account/account-page.tsx +410 -0
- package/eject/account/account-payment-method.ts +99 -0
- package/eject/account/account-referrals.ts +68 -0
- package/eject/account/account-reseller-invite.ts +88 -0
- package/eject/account/account-reseller.ts +685 -0
- package/eject/account/account-rewards.ts +104 -0
- package/eject/account/account-settings.ts +181 -0
- package/eject/account/account-signin-link.ts +122 -0
- package/eject/account/account-subscriptions.ts +130 -0
- package/eject/account/account-support.ts +155 -0
- package/eject/account/account-warranties.ts +118 -0
- package/eject/account/customer-account.ts +93 -0
- package/eject/account/dashboard.tsx +139 -0
- package/eject/account/downloads.tsx +343 -0
- package/eject/account/favorites.tsx +201 -0
- package/eject/account/i18n.ts +175 -0
- package/eject/account/icons.tsx +218 -0
- package/eject/account/login.tsx +253 -0
- package/eject/account/messages/ar.json +464 -0
- package/eject/account/messages/de.json +464 -0
- package/eject/account/messages/en.json +464 -0
- package/eject/account/messages/es.json +464 -0
- package/eject/account/messages/fi.json +464 -0
- package/eject/account/messages/fr.json +464 -0
- package/eject/account/messages/hi.json +464 -0
- package/eject/account/messages/it.json +464 -0
- package/eject/account/messages/lt.json +464 -0
- package/eject/account/messages/nl.json +464 -0
- package/eject/account/messages/pl.json +464 -0
- package/eject/account/messages/pt.json +464 -0
- package/eject/account/messages/ru.json +464 -0
- package/eject/account/messages/tr.json +464 -0
- package/eject/account/messages/uk.json +464 -0
- package/eject/account/messages/zh.json +464 -0
- package/eject/account/orders.tsx +791 -0
- package/eject/account/overview.tsx +147 -0
- package/eject/account/referrals.tsx +327 -0
- package/eject/account/reseller.tsx +1365 -0
- package/eject/account/rewards.tsx +641 -0
- package/eject/account/settings.tsx +699 -0
- package/eject/account/shared.tsx +398 -0
- package/eject/account/subscriptions.tsx +421 -0
- package/eject/account/support.tsx +453 -0
- package/package.json +79 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { SdkCustomerWarranty, SdkCustomerWarrantyClaimStatus } from '@shoppexio/storefront-react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The order detail's warranty half, decided without a word of prose.
|
|
5
|
+
*
|
|
6
|
+
* The route filters on `invoices.uniqid` when it is given one, which is the id
|
|
7
|
+
* the order on screen carries, so a detail asks for its own cover by name.
|
|
8
|
+
* There is no line-item id on that wire, so a warranty cannot be attached to
|
|
9
|
+
* the row that sold it — it belongs to the order, and this is why the order
|
|
10
|
+
* detail carries one warranty section rather than a chip inside each line.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export function warrantiesForOrder(
|
|
14
|
+
warranties: readonly SdkCustomerWarranty[],
|
|
15
|
+
invoiceUniqid: string,
|
|
16
|
+
): SdkCustomerWarranty[] {
|
|
17
|
+
return warranties.filter((warranty) => warranty.invoice_uniqid === invoiceUniqid);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What the buyer is covered for, and until when.
|
|
22
|
+
*
|
|
23
|
+
* A VOIDED warranty carries an `expires_at` like any other row, and printing it
|
|
24
|
+
* would tell a buyer whose cover was cancelled that they are protected until
|
|
25
|
+
* next March. Cancelled cover names no date at all, which is why that variant
|
|
26
|
+
* has no field to name one with.
|
|
27
|
+
*
|
|
28
|
+
* The two that do keep the producer's own string rather than a parsed Date: the
|
|
29
|
+
* panel's date formatter prints an unreadable timestamp as it came instead of
|
|
30
|
+
* as "Invalid Date", and a Date built here would have thrown that away.
|
|
31
|
+
*/
|
|
32
|
+
export type WarrantyCoverage =
|
|
33
|
+
| { kind: 'covered'; expiresAt: string }
|
|
34
|
+
| { kind: 'ended'; expiresAt: string }
|
|
35
|
+
| { kind: 'cancelled' };
|
|
36
|
+
|
|
37
|
+
export function warrantyCoverage(warranty: SdkCustomerWarranty): WarrantyCoverage {
|
|
38
|
+
switch (warranty.status) {
|
|
39
|
+
case 'ACTIVE':
|
|
40
|
+
return { kind: 'covered', expiresAt: warranty.expires_at };
|
|
41
|
+
case 'EXPIRED':
|
|
42
|
+
return { kind: 'ended', expiresAt: warranty.expires_at };
|
|
43
|
+
case 'VOIDED':
|
|
44
|
+
return { kind: 'cancelled' };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const CLAIM_KEYS: Readonly<Record<SdkCustomerWarrantyClaimStatus, string>> = {
|
|
49
|
+
REQUESTED: 'orders.warrantyClaimSubmitted',
|
|
50
|
+
APPROVED: 'orders.warrantyClaimApproved',
|
|
51
|
+
DENIED: 'orders.warrantyClaimDeclined',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Null when nothing was ever claimed — the row then says only what it covers. */
|
|
55
|
+
export function warrantyClaimKey(status: SdkCustomerWarrantyClaimStatus | null): string | null {
|
|
56
|
+
return status === null ? null : CLAIM_KEYS[status];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A producer timestamp, read the way the producer wrote it: naive strings
|
|
61
|
+
* ("2027-08-05 00:00:00.000") are UTC, not local time. Only the claim GATE
|
|
62
|
+
* parses — displayed dates keep the producer's own string (see above).
|
|
63
|
+
*/
|
|
64
|
+
function parseWarrantyTimestamp(value: string): number | null {
|
|
65
|
+
const trimmed = value.trim();
|
|
66
|
+
if (!trimmed) return null;
|
|
67
|
+
const hasTimezone = /(?:Z|[+-]\d{2}(?::?\d{2})?)$/i.test(trimmed);
|
|
68
|
+
const hasTimePart = trimmed.includes(' ') || trimmed.includes('T');
|
|
69
|
+
const normalized = hasTimezone || !hasTimePart ? trimmed : `${trimmed.replace(' ', 'T')}Z`;
|
|
70
|
+
const parsed = Date.parse(normalized);
|
|
71
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The producer's timestamp as a Date for display, or the string as it came
|
|
76
|
+
* when it cannot be read — the panel's formatter then prints the original
|
|
77
|
+
* rather than "Invalid Date". Same UTC rule as the claim gate: a naive
|
|
78
|
+
* PostgreSQL timestamp is UTC, not the buyer's local zone.
|
|
79
|
+
*/
|
|
80
|
+
export function warrantyDate(value: string): Date | string {
|
|
81
|
+
const parsed = parseWarrantyTimestamp(value);
|
|
82
|
+
return parsed === null ? value : new Date(parsed);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether a claim can still be made.
|
|
87
|
+
*
|
|
88
|
+
* All three halves matter. Cover that ended or was cancelled has nothing left
|
|
89
|
+
* to claim against, and a claim the shop is still reading — or has approved —
|
|
90
|
+
* would take a second one nobody asked for. A DECLINED claim is the exception
|
|
91
|
+
* the route itself makes: `requestClaim` refuses only a pending or approved
|
|
92
|
+
* one, and takes a declined warranty back to REQUESTED. And a stored ACTIVE
|
|
93
|
+
* whose `expires_at` has already passed is a sweep that has not run yet, not
|
|
94
|
+
* live cover — the route refuses the crossed timestamp regardless of the
|
|
95
|
+
* status, so a control shown for it would reliably answer 409. An unreadable
|
|
96
|
+
* timestamp fails closed.
|
|
97
|
+
*/
|
|
98
|
+
export function canClaimWarranty(warranty: SdkCustomerWarranty, now: Date = new Date()): boolean {
|
|
99
|
+
if (warranty.status !== 'ACTIVE') return false;
|
|
100
|
+
if (warranty.claim_status !== null && warranty.claim_status !== 'DENIED') return false;
|
|
101
|
+
const expires = parseWarrantyTimestamp(warranty.expires_at);
|
|
102
|
+
return expires !== null && expires > now.getTime();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The producer's ceiling for the message a buyer may attach to a claim. */
|
|
106
|
+
export const WARRANTY_CLAIM_MESSAGE_MAX = 2000;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The buyer's own words, or nothing at all.
|
|
110
|
+
*
|
|
111
|
+
* The field is optional upstream, and sending an empty string is not the same
|
|
112
|
+
* as omitting it: it stores a blank message the shop then reads as "the buyer
|
|
113
|
+
* had nothing to say" rather than "the buyer did not write anything".
|
|
114
|
+
*/
|
|
115
|
+
export function warrantyClaimMessage(input: string): string | undefined {
|
|
116
|
+
const message = input.trim();
|
|
117
|
+
return message.length === 0 ? undefined : message;
|
|
118
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { t } from './i18n';
|
|
2
|
+
import { getShoppex } from '@shoppexio/storefront-react';
|
|
3
|
+
import type { ShoppexSdk, SdkResponse } from '@shoppexio/storefront-react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The one way this template reaches customer data.
|
|
7
|
+
*
|
|
8
|
+
* Every account call goes through the SDK, which posts to the merchant's own
|
|
9
|
+
* origin with `credentials: 'same-origin'`. There is deliberately no fetch
|
|
10
|
+
* client here: the session is an HttpOnly cookie the Shoppex worker owns, the
|
|
11
|
+
* shop comes from the host, and a hand-rolled client is how a storefront ends
|
|
12
|
+
* up sending a shop or a token it should never have been able to choose.
|
|
13
|
+
*
|
|
14
|
+
* `getShoppex()` throws when the SDK script or a bootstrap global is missing,
|
|
15
|
+
* and an SDK build older than the customer module has no `dashboard` method at
|
|
16
|
+
* all. Both surface here as a failed call, so the panel shows an error instead
|
|
17
|
+
* of an empty account — the first carrying its own sentence, the second falling
|
|
18
|
+
* back to the panel's generic one.
|
|
19
|
+
*/
|
|
20
|
+
export async function callCustomerApi<T>(
|
|
21
|
+
operation: (sdk: ShoppexSdk) => Promise<SdkResponse<T>>,
|
|
22
|
+
): Promise<SdkResponse<T>> {
|
|
23
|
+
try {
|
|
24
|
+
return await operation(getShoppex());
|
|
25
|
+
} catch (error) {
|
|
26
|
+
// A TypeError here is the ENGINE talking, not the shop: `sdk.loyalty is not
|
|
27
|
+
// a function` is what a page gets when the CDN is still serving an SDK
|
|
28
|
+
// bundle older than this build — publish sequencing, invisible to the buyer
|
|
29
|
+
// and unfixable by them. Dropping the message hands the panel the generic
|
|
30
|
+
// sentence it already has, instead of printing a stack-trace fragment into
|
|
31
|
+
// the account page.
|
|
32
|
+
if (error instanceof TypeError) return { success: false };
|
|
33
|
+
return {
|
|
34
|
+
success: false,
|
|
35
|
+
message: error instanceof Error ? error.message : String(error),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What a panel is currently showing. `error` carries the server's own sentence
|
|
42
|
+
* rather than a generic one, because the server is the only party that knows
|
|
43
|
+
* why a call was refused.
|
|
44
|
+
*/
|
|
45
|
+
export type AccountPanelState<T> =
|
|
46
|
+
| { status: 'loading' }
|
|
47
|
+
| { status: 'error'; message: string }
|
|
48
|
+
/**
|
|
49
|
+
* `staleMessage` is set when a RE-read of data already on screen failed:
|
|
50
|
+
* the panel keeps what the buyer is looking at instead of tearing it down,
|
|
51
|
+
* but must say the figures may be out of date rather than staying silent.
|
|
52
|
+
*/
|
|
53
|
+
| { status: 'ready'; data: T; staleMessage?: string };
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The message shown when a failure reached us without one.
|
|
57
|
+
*
|
|
58
|
+
* Localized, unlike `response.message`: the server's sentence is already in the
|
|
59
|
+
* buyer's language when the server knows it, and this is the one case where
|
|
60
|
+
* nobody said anything at all.
|
|
61
|
+
*/
|
|
62
|
+
export function accountGenericError(): string {
|
|
63
|
+
return t('common.genericError');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A merchant-supplied delivery link, or nothing.
|
|
68
|
+
*
|
|
69
|
+
* A delivery of kind `link` is a URL the shop typed into their own product, and
|
|
70
|
+
* `javascript:` is a URL. An anchor carrying one runs on the origin the buyer
|
|
71
|
+
* is standing on, with their session — so it is refused here rather than
|
|
72
|
+
* rendered and hoped about. Only http, https and relative paths survive.
|
|
73
|
+
*/
|
|
74
|
+
export function safeExternalHref(href: string | null | undefined): string | null {
|
|
75
|
+
const raw = href?.trim() ?? '';
|
|
76
|
+
if (!raw) return null;
|
|
77
|
+
if (raw.startsWith('/')) return raw;
|
|
78
|
+
|
|
79
|
+
const scheme = raw.slice(0, raw.indexOf(':')).toLowerCase();
|
|
80
|
+
if (raw.includes(':') && scheme !== 'http' && scheme !== 'https') return null;
|
|
81
|
+
return raw;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function toPanelState<T>(response: SdkResponse<T>): AccountPanelState<T> {
|
|
85
|
+
if (!response.success || response.data === undefined) {
|
|
86
|
+
return {
|
|
87
|
+
status: 'error',
|
|
88
|
+
message: response.message?.trim() || accountGenericError(),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return { status: 'ready', data: response.data };
|
|
92
|
+
}
|
|
93
|
+
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reads the account shell owns, and the availability derived from them.
|
|
3
|
+
*
|
|
4
|
+
* Seven panels are built from the `dashboard()` payload and both the navigation
|
|
5
|
+
* gate and the wholesale panel need the reseller programme. Each used to ask
|
|
6
|
+
* for its own copy, which meant the shell could not gate its navigation without
|
|
7
|
+
* a SECOND request for something already on the page. The shell asks once,
|
|
8
|
+
* above the panels, and hands both down — the shape the document lane uses in
|
|
9
|
+
* `packages/storefront-commerce/src/sx/account-page.ts`.
|
|
10
|
+
*
|
|
11
|
+
* Both reads are keyed on the panel being shown, so moving between panels
|
|
12
|
+
* revalidates exactly as it did when each panel fetched on its own mount. The
|
|
13
|
+
* duplicate is what this removes; the freshness is not.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createContext, useContext, useRef, type ReactNode } from 'react';
|
|
17
|
+
import {
|
|
18
|
+
availabilityFromShellReads,
|
|
19
|
+
type AccountAvailability,
|
|
20
|
+
} from './account-availability';
|
|
21
|
+
import { narrowResellerProgram } from './account-reseller';
|
|
22
|
+
import { callCustomerApi, type AccountPanelState } from './customer-account';
|
|
23
|
+
import type { SdkCustomerDashboard } from '@shoppexio/storefront-react';
|
|
24
|
+
import { useAccountResource } from './shared';
|
|
25
|
+
|
|
26
|
+
export interface AccountResource<T> {
|
|
27
|
+
state: AccountPanelState<T>;
|
|
28
|
+
reload: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface AccountShellReads {
|
|
32
|
+
dashboard: AccountResource<SdkCustomerDashboard>;
|
|
33
|
+
/** The wholesale programme, raw. The gate and the panel narrow it themselves. */
|
|
34
|
+
resellerProgram: AccountResource<unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ShellReadsContext = createContext<AccountShellReads | null>(null);
|
|
38
|
+
|
|
39
|
+
function useShellReads(): AccountShellReads {
|
|
40
|
+
const value = useContext(ShellReadsContext);
|
|
41
|
+
if (value === null) {
|
|
42
|
+
throw new Error('account panels must be rendered inside the account shell');
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The dashboard payload, as loaded once by the shell. */
|
|
48
|
+
export function useAccountDashboard(): AccountResource<SdkCustomerDashboard> {
|
|
49
|
+
return useShellReads().dashboard;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The wholesale programme, as loaded once by the shell.
|
|
54
|
+
*
|
|
55
|
+
* The navigation gate and the wholesale panel read the SAME response. Two reads
|
|
56
|
+
* could disagree — the gate's failing while the panel's succeeded — and a buyer
|
|
57
|
+
* would see the panel call them an active reseller while the navigation had
|
|
58
|
+
* dropped the tab they reached it by.
|
|
59
|
+
*/
|
|
60
|
+
export function useAccountResellerProgram(): AccountResource<unknown> {
|
|
61
|
+
return useShellReads().resellerProgram;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Both shell reads, revalidated when the buyer moves to another panel.
|
|
66
|
+
*
|
|
67
|
+
* `panelKey` is the surface being shown. Without it the shell would load once
|
|
68
|
+
* per sign-in and never again: a buyer who bought their first download in
|
|
69
|
+
* another tab would come back, walk into Downloads and be told they have none,
|
|
70
|
+
* because the payload deciding that was read before the purchase existed.
|
|
71
|
+
*/
|
|
72
|
+
export function useAccountShellReads(panelKey: string): AccountShellReads {
|
|
73
|
+
// keepStale: the only same-key re-read of this payload is the refresh a
|
|
74
|
+
// successful redemption asks for, and tearing every panel down to an error
|
|
75
|
+
// because a background refresh failed would destroy the notice that
|
|
76
|
+
// redemption just showed. AccountRewards renders the staleMessage.
|
|
77
|
+
const dashboard = useAccountResource(`dashboard:${panelKey}`, () =>
|
|
78
|
+
callCustomerApi((sdk) => sdk.dashboard()), { keepStale: true });
|
|
79
|
+
const resellerProgram = useAccountResource(`reseller-program:${panelKey}`, () =>
|
|
80
|
+
callCustomerApi((sdk) => sdk.reseller()));
|
|
81
|
+
return { dashboard, resellerProgram };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* What the navigation may offer, or null while nothing is known yet.
|
|
86
|
+
*
|
|
87
|
+
* The dashboard gates are decided the moment the dashboard answers. They do NOT
|
|
88
|
+
* wait on the reseller read — that is a separate request for one tab, and
|
|
89
|
+
* blocking Downloads, Subscriptions, Rewards, Referrals and Favorites on it
|
|
90
|
+
* meant a slow wholesale endpoint emptied a navigation it has nothing to do
|
|
91
|
+
* with. The document lane passes whatever it holds and lets the wholesale leaf
|
|
92
|
+
* appear when its own answer arrives; this does the same.
|
|
93
|
+
*
|
|
94
|
+
* The last known answer is held across a revalidation. Otherwise every move
|
|
95
|
+
* between panels would empty the navigation and refill it a moment later, under
|
|
96
|
+
* the cursor of the buyer using it.
|
|
97
|
+
*/
|
|
98
|
+
export function useAccountAvailability(reads: AccountShellReads): AccountAvailability | null {
|
|
99
|
+
const lastKnown = useRef<AccountAvailability | null>(null);
|
|
100
|
+
const { dashboard, resellerProgram } = reads;
|
|
101
|
+
|
|
102
|
+
/*
|
|
103
|
+
* A reseller read that failed, or has not answered, is not a shop without a
|
|
104
|
+
* programme — it is an unanswered question, and the gate stays SHUT rather
|
|
105
|
+
* than offering a tab on a guess. A buyer standing on that tab keeps it:
|
|
106
|
+
* `isAccountTabOffered` never hides the active one. And because the panel
|
|
107
|
+
* shares this exact response, its retry reopens the gate.
|
|
108
|
+
*/
|
|
109
|
+
const program = resellerProgram.state.status === 'ready'
|
|
110
|
+
? narrowResellerProgram(resellerProgram.state.data)
|
|
111
|
+
: null;
|
|
112
|
+
|
|
113
|
+
const availability = availabilityFromShellReads(
|
|
114
|
+
dashboard.state.status === 'ready'
|
|
115
|
+
? {
|
|
116
|
+
licensesCount: dashboard.state.data.stats.licenses_count,
|
|
117
|
+
deliverablesCount: dashboard.state.data.stats.deliverables_count,
|
|
118
|
+
subscriptionsCount: dashboard.state.data.subscriptions.length,
|
|
119
|
+
walletPresent: dashboard.state.data.wallet !== null,
|
|
120
|
+
affiliateState: dashboard.state.data.affiliate.state,
|
|
121
|
+
}
|
|
122
|
+
: null,
|
|
123
|
+
program?.ok ? program.data : null,
|
|
124
|
+
lastKnown.current,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
lastKnown.current = availability;
|
|
128
|
+
return availability;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function AccountShellProvider({
|
|
132
|
+
value,
|
|
133
|
+
children,
|
|
134
|
+
}: {
|
|
135
|
+
value: AccountShellReads;
|
|
136
|
+
children: ReactNode;
|
|
137
|
+
}) {
|
|
138
|
+
return <ShellReadsContext.Provider value={value}>{children}</ShellReadsContext.Provider>;
|
|
139
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything the shop has already handed over: license keys, files, codes and
|
|
3
|
+
* access links.
|
|
4
|
+
*
|
|
5
|
+
* There is no `GET /deliverables` and no `GET /licenses`. Both lists arrive
|
|
6
|
+
* inside `dashboard()`, which is why this panel loads the same payload the
|
|
7
|
+
* overview does. They are two halves of one answer: the deliverable carries the
|
|
8
|
+
* key itself, the license row carries its expiry, its hardware lock and whether
|
|
9
|
+
* it can be renewed at all. `account/account-downloads.ts` performs the join
|
|
10
|
+
* and refuses to render a half of it that contradicts the other.
|
|
11
|
+
*
|
|
12
|
+
* Renewal is a purchase, so it is a link into the store rather than a call.
|
|
13
|
+
* `POST /licenses/:id/renew` raises an invoice, and ADR-0064 keeps that off the
|
|
14
|
+
* account API for good: a renewal invoice created by markup the buyer never
|
|
15
|
+
* clicked has no cause they could point at.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useState } from 'react';
|
|
19
|
+
import { Link } from 'react-router-dom';
|
|
20
|
+
import { accountOrderHref } from '@shoppexio/storefront-react';
|
|
21
|
+
import {
|
|
22
|
+
buildDownloadGroups,
|
|
23
|
+
isEmptyDownloadGroups,
|
|
24
|
+
licenseExpiryDate,
|
|
25
|
+
offersRenewal,
|
|
26
|
+
type DownloadContractError,
|
|
27
|
+
type FileDownload,
|
|
28
|
+
type LicenseDownload,
|
|
29
|
+
} from './account-downloads';
|
|
30
|
+
import { callCustomerApi, safeExternalHref } from './customer-account';
|
|
31
|
+
import type { SdkCustomerDeliverable } from '@shoppexio/storefront-react';
|
|
32
|
+
import { t } from './i18n';
|
|
33
|
+
import { ArrowRightIcon, BoxIcon, ExternalLinkIcon, LockIcon, TagIcon } from './icons';
|
|
34
|
+
import { useAccountDashboard } from './dashboard';
|
|
35
|
+
import {
|
|
36
|
+
AccountCard,
|
|
37
|
+
AccountEmpty,
|
|
38
|
+
AccountError,
|
|
39
|
+
AccountNotice,
|
|
40
|
+
AccountRowsSkeleton,
|
|
41
|
+
AccountSectionTitle,
|
|
42
|
+
BusySpinner,
|
|
43
|
+
CopyButton,
|
|
44
|
+
StatusChip,
|
|
45
|
+
formatAccountDate,
|
|
46
|
+
} from './shared';
|
|
47
|
+
|
|
48
|
+
export function AccountDownloads() {
|
|
49
|
+
const { state, reload } = useAccountDashboard();
|
|
50
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
51
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
52
|
+
const [resettingUniqid, setResettingUniqid] = useState<string | null>(null);
|
|
53
|
+
|
|
54
|
+
if (state.status === 'loading') {
|
|
55
|
+
return <AccountRowsSkeleton rows={4} label={t('downloads.loading')} />;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (state.status === 'error') {
|
|
59
|
+
return <AccountError message={state.message} onRetry={reload} />;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const grouped = buildDownloadGroups(state.data.deliverables, state.data.licenses);
|
|
63
|
+
|
|
64
|
+
// The producer contradicting itself is not an empty account. A key with no
|
|
65
|
+
// license behind it, or a file with nothing to fetch, is said out loud rather
|
|
66
|
+
// than rendered as a row that names something the page cannot hand over.
|
|
67
|
+
if (!grouped.ok) {
|
|
68
|
+
return (
|
|
69
|
+
<div className="sxa:space-y-4">
|
|
70
|
+
<AccountSectionTitle>{t('downloads.brokenTitle')}</AccountSectionTitle>
|
|
71
|
+
<AccountError message={contractErrorMessage(grouped.error)} onRetry={reload} />
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { groups } = grouped;
|
|
77
|
+
|
|
78
|
+
if (isEmptyDownloadGroups(groups)) {
|
|
79
|
+
return (
|
|
80
|
+
<AccountEmpty
|
|
81
|
+
title={t('downloads.emptyTitle')}
|
|
82
|
+
body={t('downloads.emptyBody')}
|
|
83
|
+
action={(
|
|
84
|
+
<Link to="/all-products" className="sx-account-button sxa:card-button sxa:card-button--add">
|
|
85
|
+
{t('common.browseStore')}
|
|
86
|
+
</Link>
|
|
87
|
+
)}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const resetHardwareId = async (uniqid: string) => {
|
|
93
|
+
setResettingUniqid(uniqid);
|
|
94
|
+
setNotice(null);
|
|
95
|
+
setActionError(null);
|
|
96
|
+
const result = await callCustomerApi((sdk) => sdk.resetLicenseHwid(uniqid));
|
|
97
|
+
setResettingUniqid(null);
|
|
98
|
+
|
|
99
|
+
if (!result.success) {
|
|
100
|
+
setActionError(result.message?.trim() || t('downloads.hwidResetFailed'));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setNotice(t('downloads.hwidResetDone'));
|
|
105
|
+
// The lock lives on the license row, so the payload is read again. Clearing
|
|
106
|
+
// it locally would show a buyer a released license the server never agreed
|
|
107
|
+
// to release.
|
|
108
|
+
reload();
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className="sxa:space-y-8">
|
|
113
|
+
{notice ? <AccountNotice message={notice} /> : null}
|
|
114
|
+
{actionError ? <AccountError message={actionError} /> : null}
|
|
115
|
+
|
|
116
|
+
{groups.licenses.length ? (
|
|
117
|
+
<section>
|
|
118
|
+
<AccountSectionTitle>{t('downloads.licensesTitle')}</AccountSectionTitle>
|
|
119
|
+
<ul className="sxa:mt-4 sxa:space-y-3">
|
|
120
|
+
{groups.licenses.map((entry) => (
|
|
121
|
+
<li key={entry.deliverable.id}>
|
|
122
|
+
<LicenseRow
|
|
123
|
+
entry={entry}
|
|
124
|
+
resetting={resettingUniqid === entry.license.uniqid}
|
|
125
|
+
disabled={resettingUniqid !== null}
|
|
126
|
+
onResetHardwareId={() => void resetHardwareId(entry.license.uniqid)}
|
|
127
|
+
/>
|
|
128
|
+
</li>
|
|
129
|
+
))}
|
|
130
|
+
</ul>
|
|
131
|
+
</section>
|
|
132
|
+
) : null}
|
|
133
|
+
|
|
134
|
+
{groups.files.length ? (
|
|
135
|
+
<section>
|
|
136
|
+
<AccountSectionTitle>{t('downloads.filesTitle')}</AccountSectionTitle>
|
|
137
|
+
<ul className="sxa:mt-4 sxa:space-y-3">
|
|
138
|
+
{groups.files.map((file) => (
|
|
139
|
+
<li key={file.id}>
|
|
140
|
+
<FileRow file={file} />
|
|
141
|
+
</li>
|
|
142
|
+
))}
|
|
143
|
+
</ul>
|
|
144
|
+
</section>
|
|
145
|
+
) : null}
|
|
146
|
+
|
|
147
|
+
{groups.codes.length ? (
|
|
148
|
+
<section>
|
|
149
|
+
<AccountSectionTitle>{t('downloads.codesTitle')}</AccountSectionTitle>
|
|
150
|
+
<ul className="sxa:mt-4 sxa:space-y-3">
|
|
151
|
+
{groups.codes.map((code) => (
|
|
152
|
+
<li key={code.id}>
|
|
153
|
+
<CodeRow code={code} />
|
|
154
|
+
</li>
|
|
155
|
+
))}
|
|
156
|
+
</ul>
|
|
157
|
+
</section>
|
|
158
|
+
) : null}
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function contractErrorMessage(error: DownloadContractError): string {
|
|
164
|
+
return error.reason === 'license-missing'
|
|
165
|
+
? t('downloads.brokenBody', { id: error.id })
|
|
166
|
+
: t('downloads.brokenFileBody', { id: error.id });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A delivered value the buyer has to be able to read and copy exactly. */
|
|
170
|
+
function DeliverableValue({ value }: { value: string }) {
|
|
171
|
+
return (
|
|
172
|
+
<p className="sxa:rounded-input sxa:border sxa:bg-bg sxa:px-3 sxa:py-2 sxa:font-mono sxa:text-micro sxa:break-all">{value}</p>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function ViewOrderLink({ orderId }: { orderId: string }) {
|
|
177
|
+
return (
|
|
178
|
+
<Link
|
|
179
|
+
to={accountOrderHref(orderId)}
|
|
180
|
+
className="sxa:inline-flex sxa:items-center sxa:gap-1.5 sxa:text-small sxa:transition-opacity sxa:hover:opacity-80"
|
|
181
|
+
>
|
|
182
|
+
{t('downloads.viewOrder')}
|
|
183
|
+
<ArrowRightIcon className="sxa:h-4 sxa:w-4 sxa:shrink-0" />
|
|
184
|
+
</Link>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function LicenseRow({
|
|
189
|
+
entry,
|
|
190
|
+
resetting,
|
|
191
|
+
disabled,
|
|
192
|
+
onResetHardwareId,
|
|
193
|
+
}: {
|
|
194
|
+
entry: LicenseDownload;
|
|
195
|
+
resetting: boolean;
|
|
196
|
+
disabled: boolean;
|
|
197
|
+
onResetHardwareId: () => void;
|
|
198
|
+
}) {
|
|
199
|
+
const { deliverable, license } = entry;
|
|
200
|
+
const expiry = licenseExpiryDate(license);
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<AccountCard className="sxa:px-5 sxa:py-4">
|
|
204
|
+
<div className="sxa:flex sxa:flex-wrap sxa:items-start sxa:justify-between sxa:gap-3">
|
|
205
|
+
<div className="sxa:flex sxa:min-w-0 sxa:items-start sxa:gap-3">
|
|
206
|
+
<LockIcon className="sxa:mt-0.5 sxa:h-4 sxa:w-4 sxa:shrink-0 sxa:text-muted" />
|
|
207
|
+
<div className="sxa:min-w-0">
|
|
208
|
+
<p className="sxa:truncate sxa:text-small sxa:font-semibold">{deliverable.product_title}</p>
|
|
209
|
+
{/* A null expiry means the license never ends — a different fact
|
|
210
|
+
from an expiry nobody could read, and stated as its own sentence. */}
|
|
211
|
+
<p className="sxa:mt-1 sxa:text-micro sxa:text-muted">
|
|
212
|
+
{expiry
|
|
213
|
+
? t('downloads.expires', { date: formatAccountDate(expiry) })
|
|
214
|
+
: t('downloads.neverExpires')}
|
|
215
|
+
</p>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
<StatusChip status={license.status} />
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<div className="sxa:mt-3 sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-2">
|
|
222
|
+
<div className="sxa:min-w-0 sxa:flex-1">
|
|
223
|
+
<DeliverableValue value={deliverable.value} />
|
|
224
|
+
</div>
|
|
225
|
+
<CopyButton value={deliverable.value} />
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
{license.hardware_id ? (
|
|
229
|
+
<div className="sxa:mt-3 sxa:flex sxa:flex-wrap sxa:items-center sxa:justify-between sxa:gap-2">
|
|
230
|
+
<p className="sxa:min-w-0 sxa:text-micro sxa:break-all sxa:text-muted">
|
|
231
|
+
{t('downloads.lockedTo', { hardwareId: license.hardware_id })}
|
|
232
|
+
</p>
|
|
233
|
+
<button
|
|
234
|
+
type="button"
|
|
235
|
+
onClick={onResetHardwareId}
|
|
236
|
+
disabled={disabled}
|
|
237
|
+
aria-busy={resetting || undefined}
|
|
238
|
+
className="sx-account-button sxa:card-button sxa:card-button--add sxa:disabled:cursor-not-allowed sxa:disabled:opacity-50"
|
|
239
|
+
>
|
|
240
|
+
{resetting ? <BusySpinner className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" /> : null}
|
|
241
|
+
{resetting ? t('downloads.resettingHwid') : t('downloads.resetHwid')}
|
|
242
|
+
</button>
|
|
243
|
+
</div>
|
|
244
|
+
) : null}
|
|
245
|
+
|
|
246
|
+
{/* `renewable` is the server's call — only the backend can see whether the
|
|
247
|
+
product still exists and still sells — and it is paired with an expiry,
|
|
248
|
+
so a perpetual license is never offered an end date it never had. */}
|
|
249
|
+
{offersRenewal(license) ? (
|
|
250
|
+
<div className="sxa:mt-4 sxa:border-t sxa:pt-4">
|
|
251
|
+
<p className="sxa:text-micro sxa:font-semibold sxa:tracking-[0.12em] sxa:text-muted sxa:uppercase">
|
|
252
|
+
{t('downloads.renewTitle')}
|
|
253
|
+
</p>
|
|
254
|
+
<p className="sxa:mt-1.5 sxa:text-small sxa:leading-relaxed sxa:text-muted">
|
|
255
|
+
{t('downloads.renewBody')}
|
|
256
|
+
</p>
|
|
257
|
+
<Link to="/all-products" className="sx-account-button sxa:card-button sxa:card-button--add sxa:mt-3">
|
|
258
|
+
{t('downloads.renewAction')}
|
|
259
|
+
</Link>
|
|
260
|
+
</div>
|
|
261
|
+
) : null}
|
|
262
|
+
|
|
263
|
+
<div className="sxa:mt-3">
|
|
264
|
+
<ViewOrderLink orderId={license.invoice_id} />
|
|
265
|
+
</div>
|
|
266
|
+
</AccountCard>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function FileRow({ file }: { file: FileDownload }) {
|
|
271
|
+
/*
|
|
272
|
+
* A plain link, not a fetch. The path is same-origin under `/api/customer/`,
|
|
273
|
+
* so the browser sends the session cookie the Shoppex worker owns — there is
|
|
274
|
+
* no token to attach here, and nothing in this bundle could attach one.
|
|
275
|
+
*
|
|
276
|
+
* It is still checked: a path is merchant-adjacent data, and an anchor whose
|
|
277
|
+
* href turns out to be a script URL runs on the buyer's own origin with their
|
|
278
|
+
* session. A value that does not survive the check gets no anchor at all,
|
|
279
|
+
* because a dead link dressed as a button is worse than a plain refusal.
|
|
280
|
+
*/
|
|
281
|
+
const href = safeExternalHref(file.href);
|
|
282
|
+
|
|
283
|
+
return (
|
|
284
|
+
<AccountCard className="sxa:flex sxa:flex-wrap sxa:items-center sxa:gap-4 sxa:px-5 sxa:py-4">
|
|
285
|
+
<BoxIcon className="sxa:h-4 sxa:w-4 sxa:shrink-0 sxa:text-muted" />
|
|
286
|
+
<div className="sxa:min-w-0 sxa:flex-1">
|
|
287
|
+
<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>
|
|
289
|
+
</div>
|
|
290
|
+
{href === null ? (
|
|
291
|
+
<span className="sx-account-chip sxa:meta-chip sxa:meta-chip--neutral">{t('common.unavailable')}</span>
|
|
292
|
+
) : (
|
|
293
|
+
<a href={href} download className="sx-account-button sxa:card-button sxa:card-button--add sxa:shrink-0">
|
|
294
|
+
{t('downloads.download')}
|
|
295
|
+
</a>
|
|
296
|
+
)}
|
|
297
|
+
</AccountCard>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function CodeRow({ code }: { code: SdkCustomerDeliverable }) {
|
|
302
|
+
// A code delivery may carry an access link the merchant typed themselves.
|
|
303
|
+
// Checked before it becomes an anchor, for the same reason as a file path.
|
|
304
|
+
const href = safeExternalHref(code.href);
|
|
305
|
+
|
|
306
|
+
return (
|
|
307
|
+
<AccountCard className="sxa:px-5 sxa:py-4">
|
|
308
|
+
<div className="sxa:flex sxa:flex-wrap sxa:items-start sxa:justify-between sxa:gap-3">
|
|
309
|
+
<div className="sxa:flex sxa:min-w-0 sxa:items-start sxa:gap-3">
|
|
310
|
+
<TagIcon className="sxa:mt-0.5 sxa:h-4 sxa:w-4 sxa:shrink-0 sxa:text-muted" />
|
|
311
|
+
<div className="sxa:min-w-0">
|
|
312
|
+
<p className="sxa:truncate sxa:text-small sxa:font-semibold">{code.label}</p>
|
|
313
|
+
<p className="sxa:mt-1 sxa:truncate sxa:text-micro sxa:text-muted">{code.product_title}</p>
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
{href === null ? (
|
|
317
|
+
<CopyButton value={code.value} className="sxa:shrink-0" />
|
|
318
|
+
) : (
|
|
319
|
+
<a
|
|
320
|
+
href={href}
|
|
321
|
+
target="_blank"
|
|
322
|
+
rel="noreferrer noopener"
|
|
323
|
+
className="sx-account-button sxa:card-button sxa:card-button--add sxa:shrink-0"
|
|
324
|
+
>
|
|
325
|
+
<ExternalLinkIcon className="sxa:mr-1.5 sxa:h-3.5 sxa:w-3.5" />
|
|
326
|
+
{t('downloads.openLink')}
|
|
327
|
+
</a>
|
|
328
|
+
)}
|
|
329
|
+
</div>
|
|
330
|
+
|
|
331
|
+
{/* The value is what the buyer keeps when there is no link to follow. */}
|
|
332
|
+
{href === null ? (
|
|
333
|
+
<div className="sxa:mt-3">
|
|
334
|
+
<DeliverableValue value={code.value} />
|
|
335
|
+
</div>
|
|
336
|
+
) : null}
|
|
337
|
+
|
|
338
|
+
<div className="sxa:mt-3">
|
|
339
|
+
<ViewOrderLink orderId={code.invoice_uniqid} />
|
|
340
|
+
</div>
|
|
341
|
+
</AccountCard>
|
|
342
|
+
);
|
|
343
|
+
}
|