@shoppexio/storefront-react 0.0.0 → 0.1.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 +20 -0
- package/LICENSE +21 -0
- package/README.md +137 -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 +75 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which account destinations this customer has anything behind.
|
|
3
|
+
*
|
|
4
|
+
* ADR-0064 names the gating model from the retired portal's `visibleSections()`
|
|
5
|
+
* as part of the contract, to be reproduced rather than simplified away, and
|
|
6
|
+
* the document lane reproduces it in
|
|
7
|
+
* `packages/storefront-commerce/src/sx/account-nav-core.ts`. This is the same
|
|
8
|
+
* model for the code lane. It is a separate module, not a few conditions inside
|
|
9
|
+
* the navigation, because the rules are a contract two lanes have to agree on —
|
|
10
|
+
* and because a rule inside JSX is a rule no test can read.
|
|
11
|
+
*
|
|
12
|
+
* The counters come off the ONE `dashboard()` payload the shell already loads,
|
|
13
|
+
* so gating costs no request. `reseller` is the exception: it lives behind its
|
|
14
|
+
* own read and is passed in.
|
|
15
|
+
*
|
|
16
|
+
* Two leaves are deliberately ungated, and the reasoning is the portal's:
|
|
17
|
+
*
|
|
18
|
+
* - `rewards` — the dashboard assigns the rewards object unconditionally and
|
|
19
|
+
* the producer returns a zeroed summary when there is nothing to report, so
|
|
20
|
+
* its presence carries no information. Gating it would need a programme flag
|
|
21
|
+
* the payload does not send; inferring one is the invented value the repo's
|
|
22
|
+
* fallback policy prohibits.
|
|
23
|
+
* - `favorites` — offered to every customer.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { ResellerProgramState } from './account-reseller';
|
|
27
|
+
import type { AccountTab } from '@shoppexio/storefront-react';
|
|
28
|
+
|
|
29
|
+
/** The signals a gate is decided on, read off the dashboard payload. */
|
|
30
|
+
export interface AccountAvailabilitySource {
|
|
31
|
+
licensesCount: number;
|
|
32
|
+
deliverablesCount: number;
|
|
33
|
+
subscriptionsCount: number;
|
|
34
|
+
walletPresent: boolean;
|
|
35
|
+
affiliateState: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AccountAvailability {
|
|
39
|
+
downloads: boolean;
|
|
40
|
+
subscriptions: boolean;
|
|
41
|
+
wallet: boolean;
|
|
42
|
+
rewards: boolean;
|
|
43
|
+
referrals: boolean;
|
|
44
|
+
favorites: boolean;
|
|
45
|
+
reseller: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The gate each tab hangs on. `always` is the four leaves every customer has:
|
|
50
|
+
* the overview, their orders, support and their own settings.
|
|
51
|
+
*
|
|
52
|
+
* `wallet` is in the gate set but is NOT a tab — it is the embedded section
|
|
53
|
+
* inside the overview, gated on the same signal.
|
|
54
|
+
*/
|
|
55
|
+
export type AccountGate =
|
|
56
|
+
| 'always'
|
|
57
|
+
| 'downloads'
|
|
58
|
+
| 'subscriptions'
|
|
59
|
+
| 'wallet'
|
|
60
|
+
| 'rewards'
|
|
61
|
+
| 'referrals'
|
|
62
|
+
| 'favorites'
|
|
63
|
+
| 'reseller';
|
|
64
|
+
|
|
65
|
+
const TAB_GATES: Readonly<Record<AccountTab, AccountGate>> = {
|
|
66
|
+
overview: 'always',
|
|
67
|
+
orders: 'always',
|
|
68
|
+
support: 'always',
|
|
69
|
+
settings: 'always',
|
|
70
|
+
downloads: 'downloads',
|
|
71
|
+
subscriptions: 'subscriptions',
|
|
72
|
+
rewards: 'rewards',
|
|
73
|
+
referrals: 'referrals',
|
|
74
|
+
favorites: 'favorites',
|
|
75
|
+
reseller: 'reseller',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export function accountTabGate(tab: AccountTab): AccountGate {
|
|
79
|
+
return TAB_GATES[tab];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function accountAvailabilityFrom(
|
|
83
|
+
source: AccountAvailabilitySource,
|
|
84
|
+
reseller: ResellerProgramState | null,
|
|
85
|
+
): AccountAvailability {
|
|
86
|
+
return {
|
|
87
|
+
downloads: source.licensesCount > 0 || source.deliverablesCount > 0,
|
|
88
|
+
subscriptions: source.subscriptionsCount > 0,
|
|
89
|
+
wallet: source.walletPresent,
|
|
90
|
+
rewards: true,
|
|
91
|
+
referrals: source.affiliateState !== 'not_enrolled',
|
|
92
|
+
favorites: true,
|
|
93
|
+
reseller: accountResellerAvailable(reseller),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A shop with no programme has no wholesale surface. One that enrolls by
|
|
99
|
+
* invitation only (`MANUAL`) shows the tab to the invited and to nobody else;
|
|
100
|
+
* an `APPLICATION` or `OPEN` programme shows it to everyone, because applying
|
|
101
|
+
* is the point of the tab.
|
|
102
|
+
*/
|
|
103
|
+
export function accountResellerAvailable(program: ResellerProgramState | null): boolean {
|
|
104
|
+
if (!program?.program_enabled) return false;
|
|
105
|
+
return program.reseller !== null || program.enrollment_mode.toUpperCase() !== 'MANUAL';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* What the navigation may offer given what has answered SO FAR.
|
|
110
|
+
*
|
|
111
|
+
* Pure, and separate from the hook that calls it, because the sequencing is the
|
|
112
|
+
* part that was wrong: the dashboard gates were made to wait on the reseller
|
|
113
|
+
* read, so a slow wholesale endpoint emptied a navigation it has nothing to do
|
|
114
|
+
* with. Three rules, each of which a test can hold:
|
|
115
|
+
*
|
|
116
|
+
* - the dashboard gates are decided the moment the dashboard answers, and never
|
|
117
|
+
* wait on anything else;
|
|
118
|
+
* - a reseller programme that has not answered, or failed to, leaves that one
|
|
119
|
+
* gate shut — it is an unanswered question, not a shop without a programme;
|
|
120
|
+
* - while a revalidation is in flight the last known answer stands, so moving
|
|
121
|
+
* between panels does not empty the navigation and refill it.
|
|
122
|
+
*/
|
|
123
|
+
export function availabilityFromShellReads(
|
|
124
|
+
dashboard: AccountAvailabilitySource | null,
|
|
125
|
+
program: ResellerProgramState | null,
|
|
126
|
+
lastKnown: AccountAvailability | null,
|
|
127
|
+
): AccountAvailability | null {
|
|
128
|
+
if (dashboard === null) return lastKnown;
|
|
129
|
+
|
|
130
|
+
const availability = accountAvailabilityFrom(dashboard, program);
|
|
131
|
+
|
|
132
|
+
/*
|
|
133
|
+
* The two reads revalidate together and answer separately. When the dashboard
|
|
134
|
+
* lands first, this used to recompute the wholesale gate as shut — so an
|
|
135
|
+
* eligible buyer moving from the overview to their orders watched the
|
|
136
|
+
* Wholesale tab vanish and come back a moment later. Holding the previous
|
|
137
|
+
* answer for that one gate is not inventing a value: it is the last thing the
|
|
138
|
+
* producer actually said, kept until it says otherwise. On a first load there
|
|
139
|
+
* is nothing to hold and the gate stays shut.
|
|
140
|
+
*/
|
|
141
|
+
if (program === null && lastKnown !== null) {
|
|
142
|
+
return { ...availability, reseller: lastKnown.reseller };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return availability;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Whether a tab is offered.
|
|
150
|
+
*
|
|
151
|
+
* The active tab is never hidden — that rule is named in ADR-0064 and it is
|
|
152
|
+
* what keeps a buyer who followed a link from an order email from finding the
|
|
153
|
+
* navigation missing the very page they are standing on. Hiding it would leave
|
|
154
|
+
* them looking at a panel with no way back to it.
|
|
155
|
+
*/
|
|
156
|
+
export function isAccountTabOffered(
|
|
157
|
+
tab: AccountTab,
|
|
158
|
+
availability: AccountAvailability | null,
|
|
159
|
+
activeTab: AccountTab | null,
|
|
160
|
+
): boolean {
|
|
161
|
+
if (tab === activeTab) return true;
|
|
162
|
+
|
|
163
|
+
const gate = accountTabGate(tab);
|
|
164
|
+
if (gate === 'always') return true;
|
|
165
|
+
|
|
166
|
+
// Nothing has answered yet. A gated leaf stays hidden rather than appearing
|
|
167
|
+
// and then vanishing under the buyer's cursor a moment later.
|
|
168
|
+
if (availability === null) return false;
|
|
169
|
+
|
|
170
|
+
return availability[gate];
|
|
171
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { SdkCustomerDeliverable, SdkCustomerLicense } from '@shoppexio/storefront-react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The downloads panel's pure half.
|
|
5
|
+
*
|
|
6
|
+
* The panel renders two lists that arrive as one: `deliverables` is what the
|
|
7
|
+
* shop handed over (keys, files, codes), `licenses` is the standing of each
|
|
8
|
+
* license behind those keys. They are joined on the `license:<uniqid>` id the
|
|
9
|
+
* producer builds in `buildCustomerPortalDeliverables`, and that join is the
|
|
10
|
+
* only place the two payloads meet.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const LICENSE_DELIVERABLE_PREFIX = 'license:';
|
|
14
|
+
|
|
15
|
+
export interface LicenseDownload {
|
|
16
|
+
deliverable: SdkCustomerDeliverable;
|
|
17
|
+
license: SdkCustomerLicense;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A file deliverable, narrowed: a file with nothing to fetch never gets here. */
|
|
21
|
+
export interface FileDownload extends SdkCustomerDeliverable {
|
|
22
|
+
href: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DownloadGroups {
|
|
26
|
+
/** License keys, each paired with the license row that describes it. */
|
|
27
|
+
licenses: LicenseDownload[];
|
|
28
|
+
/** Files, each with a same-origin download path. */
|
|
29
|
+
files: FileDownload[];
|
|
30
|
+
/** Codes and access links delivered with an order. */
|
|
31
|
+
codes: SdkCustomerDeliverable[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Why a payload could not be listed.
|
|
36
|
+
*
|
|
37
|
+
* Both cases are the producer contradicting itself, not a buyer with nothing
|
|
38
|
+
* to download: a license key whose license is missing, and a file with nothing
|
|
39
|
+
* to fetch. Neither is rendered as a blank row — a buyer told "here is your
|
|
40
|
+
* file" with no way to get it has been lied to, and a licence with no expiry
|
|
41
|
+
* shown might be an expiry that was never read.
|
|
42
|
+
*/
|
|
43
|
+
export type DownloadContractError =
|
|
44
|
+
| { reason: 'license-missing'; id: string }
|
|
45
|
+
| { reason: 'file-without-link'; id: string };
|
|
46
|
+
|
|
47
|
+
export type DownloadGroupsResult =
|
|
48
|
+
| { ok: true; groups: DownloadGroups }
|
|
49
|
+
| { ok: false; error: DownloadContractError };
|
|
50
|
+
|
|
51
|
+
/** The license uniqid a `license:` deliverable names, or null for other kinds. */
|
|
52
|
+
export function licenseUniqidFromDeliverable(deliverable: SdkCustomerDeliverable): string | null {
|
|
53
|
+
if (deliverable.kind !== 'license') return null;
|
|
54
|
+
if (!deliverable.id.startsWith(LICENSE_DELIVERABLE_PREFIX)) return null;
|
|
55
|
+
const uniqid = deliverable.id.slice(LICENSE_DELIVERABLE_PREFIX.length);
|
|
56
|
+
return uniqid.length > 0 ? uniqid : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildDownloadGroups(
|
|
60
|
+
deliverables: readonly SdkCustomerDeliverable[],
|
|
61
|
+
licenses: readonly SdkCustomerLicense[],
|
|
62
|
+
): DownloadGroupsResult {
|
|
63
|
+
const licensesByUniqid = new Map(licenses.map((license) => [license.uniqid, license]));
|
|
64
|
+
const groups: DownloadGroups = { licenses: [], files: [], codes: [] };
|
|
65
|
+
|
|
66
|
+
for (const deliverable of deliverables) {
|
|
67
|
+
if (deliverable.kind === 'license') {
|
|
68
|
+
const uniqid = licenseUniqidFromDeliverable(deliverable);
|
|
69
|
+
const license = uniqid === null ? undefined : licensesByUniqid.get(uniqid);
|
|
70
|
+
if (!license) return { ok: false, error: { reason: 'license-missing', id: deliverable.id } };
|
|
71
|
+
groups.licenses.push({ deliverable, license });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (deliverable.kind === 'file') {
|
|
76
|
+
if (!deliverable.href) {
|
|
77
|
+
return { ok: false, error: { reason: 'file-without-link', id: deliverable.id } };
|
|
78
|
+
}
|
|
79
|
+
groups.files.push({ ...deliverable, href: deliverable.href });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
groups.codes.push(deliverable);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { ok: true, groups };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isEmptyDownloadGroups(groups: DownloadGroups): boolean {
|
|
90
|
+
return groups.licenses.length === 0 && groups.files.length === 0 && groups.codes.length === 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A license's end date.
|
|
95
|
+
*
|
|
96
|
+
* `expires_at` is EPOCH SECONDS and `null` means the license never ends — a
|
|
97
|
+
* different statement from "we could not read the expiry", which is why this
|
|
98
|
+
* returns `null` only for the first and nothing else can produce it.
|
|
99
|
+
*/
|
|
100
|
+
export function licenseExpiryDate(license: SdkCustomerLicense): Date | null {
|
|
101
|
+
if (license.expires_at === null) return null;
|
|
102
|
+
return new Date(license.expires_at * 1000);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether to offer the buyer a way to renew.
|
|
107
|
+
*
|
|
108
|
+
* Both halves are required. `renewable` alone would offer a renewal on a
|
|
109
|
+
* perpetual license, which would hand it an end date it never had; an expiry
|
|
110
|
+
* alone would offer one for a product that has stopped selling.
|
|
111
|
+
*
|
|
112
|
+
* The offer is a link into the store, never a call: a renewal is a purchase,
|
|
113
|
+
* and purchases carry their authorisation in checkout (ADR-0064).
|
|
114
|
+
*/
|
|
115
|
+
export function offersRenewal(license: SdkCustomerLicense): boolean {
|
|
116
|
+
return license.renewable && license.expires_at !== null;
|
|
117
|
+
}
|
|
118
|
+
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The favorites panel's pure half.
|
|
3
|
+
*
|
|
4
|
+
* `GET /favorites` is one of the endpoints `@shoppex/contracts` does not
|
|
5
|
+
* describe, so the SDK answers `unknown` and the narrowing is here, written
|
|
6
|
+
* against what `customer-favorites.ts` actually selects. Every field it sends
|
|
7
|
+
* is required: a saved product with no title and no price is not a favorite the
|
|
8
|
+
* buyer can act on, and rendering it as an empty card would hide the fault.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface CustomerFavorite {
|
|
12
|
+
product_uniqid: string;
|
|
13
|
+
title: string;
|
|
14
|
+
/** Null for a product with no slug — such a product has no page to open. */
|
|
15
|
+
slug: string | null;
|
|
16
|
+
image_url: string | null;
|
|
17
|
+
price_display: number;
|
|
18
|
+
/** True once the merchant deletes the product. The row stays, the link goes. */
|
|
19
|
+
unavailable: boolean;
|
|
20
|
+
created_at: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type FavoritesResult =
|
|
24
|
+
| { ok: true; favorites: CustomerFavorite[] }
|
|
25
|
+
| { ok: false };
|
|
26
|
+
|
|
27
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
28
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readNullableString(record: Record<string, unknown>, key: string): string | null | undefined {
|
|
32
|
+
const value = record[key];
|
|
33
|
+
if (value === null || typeof value === 'string') return value;
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function narrowFavorites(payload: unknown): FavoritesResult {
|
|
38
|
+
if (!isRecord(payload) || !Array.isArray(payload.favorites)) return { ok: false };
|
|
39
|
+
|
|
40
|
+
const favorites: CustomerFavorite[] = [];
|
|
41
|
+
for (const entry of payload.favorites) {
|
|
42
|
+
if (!isRecord(entry)) return { ok: false };
|
|
43
|
+
|
|
44
|
+
const productUniqid = entry.product_uniqid;
|
|
45
|
+
const title = entry.title;
|
|
46
|
+
const slug = readNullableString(entry, 'slug');
|
|
47
|
+
const imageUrl = readNullableString(entry, 'image_url');
|
|
48
|
+
const priceDisplay = entry.price_display;
|
|
49
|
+
const unavailable = entry.unavailable;
|
|
50
|
+
const createdAt = entry.created_at;
|
|
51
|
+
|
|
52
|
+
if (
|
|
53
|
+
typeof productUniqid !== 'string'
|
|
54
|
+
|| typeof title !== 'string'
|
|
55
|
+
|| slug === undefined
|
|
56
|
+
|| imageUrl === undefined
|
|
57
|
+
|| typeof priceDisplay !== 'number'
|
|
58
|
+
|| !Number.isFinite(priceDisplay)
|
|
59
|
+
|| typeof unavailable !== 'boolean'
|
|
60
|
+
|| typeof createdAt !== 'string'
|
|
61
|
+
) {
|
|
62
|
+
return { ok: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
favorites.push({
|
|
66
|
+
product_uniqid: productUniqid,
|
|
67
|
+
title,
|
|
68
|
+
slug,
|
|
69
|
+
image_url: imageUrl,
|
|
70
|
+
price_display: priceDisplay,
|
|
71
|
+
unavailable,
|
|
72
|
+
created_at: createdAt,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { ok: true, favorites };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Where a saved product lives in this storefront, or null when it has nowhere
|
|
81
|
+
* to go.
|
|
82
|
+
*
|
|
83
|
+
* A product the merchant deleted keeps its row so the buyer can clear it, but
|
|
84
|
+
* it must not keep its link: `/product/<slug>` would answer with the storefront's
|
|
85
|
+
* not-found page, which reads as a broken shop rather than a removed product.
|
|
86
|
+
*/
|
|
87
|
+
export function favoriteProductHref(favorite: CustomerFavorite): string | null {
|
|
88
|
+
if (favorite.unavailable || !favorite.slug) return null;
|
|
89
|
+
return `/product/${encodeURIComponent(favorite.slug)}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Drops one product from a list, used after the server confirms the removal. */
|
|
93
|
+
export function withoutFavorite(
|
|
94
|
+
favorites: readonly CustomerFavorite[],
|
|
95
|
+
productUniqid: string,
|
|
96
|
+
): CustomerFavorite[] {
|
|
97
|
+
return favorites.filter((favorite) => favorite.product_uniqid !== productUniqid);
|
|
98
|
+
}
|
|
99
|
+
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { SdkCustomerLoyalty, SdkCustomerLoyaltyTransactionType } from '@shoppexio/storefront-react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The points section's pure half.
|
|
5
|
+
*
|
|
6
|
+
* Every figure here is the server's. Nothing recomputes a balance from the
|
|
7
|
+
* activity list: the list is capped at fifty entries, so adding it up would
|
|
8
|
+
* produce a second, smaller number for the same points and the two would
|
|
9
|
+
* disagree on every account older than fifty transactions.
|
|
10
|
+
*
|
|
11
|
+
* The one number this module does compute is the redeem PREVIEW, and it is
|
|
12
|
+
* labelled as an estimate wherever it is shown. The server converts the points
|
|
13
|
+
* and answers with the amount it actually credited; that answer is what the
|
|
14
|
+
* buyer is told afterwards.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The balance the server holds. No account yet is zero points, not "unknown". */
|
|
18
|
+
export function loyaltyPointsBalance(account: SdkCustomerLoyalty['account']): number {
|
|
19
|
+
return account?.points_balance ?? 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loyaltyLifetimePoints(account: SdkCustomerLoyalty['account']): number {
|
|
23
|
+
return account?.lifetime_points ?? 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Why a row exists, as a message key.
|
|
28
|
+
*
|
|
29
|
+
* The four types are a CLOSED set on this wire — the SDK validates the payload
|
|
30
|
+
* against them, so a type invented upstream fails the whole read rather than
|
|
31
|
+
* arriving here. That is why this map is total and has no "something else"
|
|
32
|
+
* branch: an unreachable one would be a label nobody could ever see.
|
|
33
|
+
*/
|
|
34
|
+
const TRANSACTION_KEYS: Readonly<Record<SdkCustomerLoyaltyTransactionType, string>> = {
|
|
35
|
+
EARN_ORDER: 'rewards.pointsFromOrder',
|
|
36
|
+
EARN_REVIEW: 'rewards.pointsFromReview',
|
|
37
|
+
REDEEM: 'rewards.pointsConverted',
|
|
38
|
+
ADJUST: 'rewards.pointsAdjusted',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function loyaltyTransactionKey(type: SdkCustomerLoyaltyTransactionType): string {
|
|
42
|
+
return TRANSACTION_KEYS[type];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `+120` for points that arrived, `-500` for points that were spent.
|
|
47
|
+
*
|
|
48
|
+
* The sign is the producer's: REDEEM and a negative ADJUST already carry a
|
|
49
|
+
* negative number, so nothing here decides direction. The plus is added because
|
|
50
|
+
* a bare `120` beside a `-500` reads as two unrelated figures.
|
|
51
|
+
*/
|
|
52
|
+
export function signedPoints(points: number): string {
|
|
53
|
+
return points > 0 ? `+${points}` : String(points);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether the buyer can convert points at all, and on what terms.
|
|
58
|
+
*
|
|
59
|
+
* A shop with no settings, or points worth nothing per point, runs no
|
|
60
|
+
* conversion: nothing is offered and nothing is said, because there is no
|
|
61
|
+
* conversion to talk about. The control is removed rather than disabled — a
|
|
62
|
+
* disabled button still promises that pressing it would have worked.
|
|
63
|
+
*
|
|
64
|
+
* A balance under the minimum is a THIRD answer, not the same as the first two.
|
|
65
|
+
* The form would be a control the route refuses, but the number it refuses
|
|
66
|
+
* below is the whole point of collecting points, so the caller keeps it and
|
|
67
|
+
* names it. A balance with nothing beside it tells the buyer nothing about what
|
|
68
|
+
* they are collecting towards.
|
|
69
|
+
*
|
|
70
|
+
* The minimum is floored at one because the route refuses anything below it: a
|
|
71
|
+
* shop that set `min_redeem_points` to zero has not made zero points
|
|
72
|
+
* convertible, it has left the field at its default.
|
|
73
|
+
*/
|
|
74
|
+
export type RedeemOffer =
|
|
75
|
+
| { kind: 'hidden' }
|
|
76
|
+
| { kind: 'below-minimum'; balance: number; minimumPoints: number }
|
|
77
|
+
| { kind: 'offered'; balance: number; minimumPoints: number; valuePerPoint: number };
|
|
78
|
+
|
|
79
|
+
export function redeemOffer(loyalty: SdkCustomerLoyalty): RedeemOffer {
|
|
80
|
+
const settings = loyalty.settings;
|
|
81
|
+
if (settings === null) return { kind: 'hidden' };
|
|
82
|
+
if (settings.redeem_value_per_point <= 0) return { kind: 'hidden' };
|
|
83
|
+
|
|
84
|
+
const minimumPoints = Math.max(1, settings.min_redeem_points);
|
|
85
|
+
const balance = loyaltyPointsBalance(loyalty.account);
|
|
86
|
+
if (balance < minimumPoints) return { kind: 'below-minimum', balance, minimumPoints };
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
kind: 'offered',
|
|
90
|
+
balance,
|
|
91
|
+
minimumPoints,
|
|
92
|
+
valuePerPoint: settings.redeem_value_per_point,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What the typed amount is worth, or why it is not an amount yet.
|
|
98
|
+
*
|
|
99
|
+
* The wire takes a whole number of one or more, so `1.5`, `-20` and `2e3` are
|
|
100
|
+
* refused HERE rather than sent and rejected: a buyer who mistyped learns it
|
|
101
|
+
* while the field is still under their cursor.
|
|
102
|
+
*
|
|
103
|
+
* `below-minimum` and `above-balance` are separate answers because they need
|
|
104
|
+
* different sentences — one names the floor the shop set, the other names the
|
|
105
|
+
* points the buyer actually has.
|
|
106
|
+
*/
|
|
107
|
+
export type RedeemPreview =
|
|
108
|
+
| { kind: 'empty' }
|
|
109
|
+
| { kind: 'not-a-number' }
|
|
110
|
+
| { kind: 'below-minimum'; minimumPoints: number }
|
|
111
|
+
| { kind: 'above-balance'; balance: number }
|
|
112
|
+
| { kind: 'zero-value' }
|
|
113
|
+
| { kind: 'ready'; points: number; amount: string };
|
|
114
|
+
|
|
115
|
+
export function redeemPreview(
|
|
116
|
+
input: string,
|
|
117
|
+
offer: { balance: number; minimumPoints: number; valuePerPoint: number },
|
|
118
|
+
): RedeemPreview {
|
|
119
|
+
const typed = input.trim();
|
|
120
|
+
if (typed.length === 0) return { kind: 'empty' };
|
|
121
|
+
if (!/^\d+$/.test(typed)) return { kind: 'not-a-number' };
|
|
122
|
+
|
|
123
|
+
const points = Number(typed);
|
|
124
|
+
if (!Number.isSafeInteger(points) || points < 1) return { kind: 'not-a-number' };
|
|
125
|
+
if (points < offer.minimumPoints) return { kind: 'below-minimum', minimumPoints: offer.minimumPoints };
|
|
126
|
+
if (points > offer.balance) return { kind: 'above-balance', balance: offer.balance };
|
|
127
|
+
|
|
128
|
+
const amount = storeCreditAmount(points, offer.valuePerPoint);
|
|
129
|
+
// A rate low enough that this many points still round to nothing. The offer
|
|
130
|
+
// itself is real — the shop converts, and a larger amount would be worth
|
|
131
|
+
// something — so this is a fact about the number typed, not about the shop,
|
|
132
|
+
// and it is refused the way any other unusable entry is.
|
|
133
|
+
if (!(Number(amount) > 0)) return { kind: 'zero-value' };
|
|
134
|
+
|
|
135
|
+
return { kind: 'ready', points, amount };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Points as a decimal string in the SHOP currency, which is the currency
|
|
140
|
+
* `redeem_value_per_point` is measured in.
|
|
141
|
+
*
|
|
142
|
+
* A STRING because that is what this template's money formatter takes
|
|
143
|
+
* everywhere else, and because a float carried to the screen is how `5` becomes
|
|
144
|
+
* `5.000000000000001`. Two decimals, and the buyer is told the figure is
|
|
145
|
+
* approximate: the server holds the authoritative rounding and answers with the
|
|
146
|
+
* amount it credited.
|
|
147
|
+
*/
|
|
148
|
+
export function storeCreditAmount(points: number, valuePerPoint: number): string {
|
|
149
|
+
return (points * valuePerPoint).toFixed(2);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A loyalty movement's timestamp as a Date for display, or the string as it
|
|
154
|
+
* came when it cannot be read — the panel's formatter then prints the
|
|
155
|
+
* original rather than "Invalid Date". Same UTC rule as the warranty dates:
|
|
156
|
+
* the producer's naive PostgreSQL timestamp is UTC, not the buyer's local
|
|
157
|
+
* zone, and the space-separated form is not something every engine parses.
|
|
158
|
+
*/
|
|
159
|
+
export function loyaltyDate(value: string): Date | string {
|
|
160
|
+
const trimmed = value.trim();
|
|
161
|
+
if (!trimmed) return value;
|
|
162
|
+
const hasTimezone = /(?:Z|[+-]\d{2}(?::?\d{2})?)$/i.test(trimmed);
|
|
163
|
+
const hasTimePart = trimmed.includes(' ') || trimmed.includes('T');
|
|
164
|
+
const normalized = hasTimezone || !hasTimePart ? trimmed : `${trimmed.replace(' ', 'T')}Z`;
|
|
165
|
+
const parsed = Date.parse(normalized);
|
|
166
|
+
return Number.isFinite(parsed) ? new Date(parsed) : value;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A unique per-intent seed. randomUUID where available, otherwise hex from
|
|
170
|
+
* getRandomValues — the derived key stamps its own RFC 4122 shape, so the
|
|
171
|
+
* seed only has to be unique, and minting must never throw during render. */
|
|
172
|
+
export function mintRedeemSeed(): string {
|
|
173
|
+
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
|
174
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
175
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The idempotency key for one redeem intent, derived from a per-intent seed
|
|
180
|
+
* and the amount instead of minted per keystroke. Any way the buyer arrives
|
|
181
|
+
* back at the same amount — retype, clear-and-repeat, edit-and-undo — yields
|
|
182
|
+
* the SAME key while the seed lives, so a retry after a dropped connection can
|
|
183
|
+
* never spend the points twice; the seed is rotated only when the server has
|
|
184
|
+
* answered success. A different amount derives a different key, so a stale key
|
|
185
|
+
* can never replay the wrong conversion.
|
|
186
|
+
*/
|
|
187
|
+
export async function deriveRedeemIdempotencyKey(seed: string, points: number): Promise<string> {
|
|
188
|
+
// A deliberate, checked use of the global: this derivation is pure — it takes
|
|
189
|
+
// the seed it is handed and returns a string — so it owns no window state.
|
|
190
|
+
// The seed still arrives through the React seam. `crypto.subtle` exists only
|
|
191
|
+
// in a secure context, and a storefront that is not one has to say so rather
|
|
192
|
+
// than throw a DOM exception at the buyer.
|
|
193
|
+
if (!globalThis.crypto?.subtle) throw new Error('Redeeming points needs Web Crypto (a secure context).');
|
|
194
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`${seed}:${points}`));
|
|
195
|
+
const bytes = new Uint8Array(digest).slice(0, 16);
|
|
196
|
+
// RFC 4122 shape (version + variant bits): the route validates the key with
|
|
197
|
+
// `format: 'uuid'`.
|
|
198
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
199
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
200
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
201
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The redeem intent's state, at MODULE level on purpose: a buyer who
|
|
206
|
+
* navigates to another account tab unmounts the rewards view, and a
|
|
207
|
+
* component-held key for a committed-but-unanswered conversion would die
|
|
208
|
+
* with it — retrying after coming back would derive a fresh key and debit
|
|
209
|
+
* the points a second time. Module state lives as long as the page, the
|
|
210
|
+
* same lifetime the document lane's panel surface has.
|
|
211
|
+
*
|
|
212
|
+
* A different signed-in buyer reusing a leftover key is harmless: the
|
|
213
|
+
* producer scopes idempotency to the account, so an unknown key simply
|
|
214
|
+
* executes as a first attempt.
|
|
215
|
+
*/
|
|
216
|
+
let redeemSeed = '';
|
|
217
|
+
const pendingRedeemKeys = new Map<number, string>();
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The submission key for this amount: the remembered one while the amount
|
|
221
|
+
* is still unanswered, otherwise derived from the current seed and
|
|
222
|
+
* remembered until `resolveRedeemKey` drops it on success.
|
|
223
|
+
*/
|
|
224
|
+
export async function redeemSubmissionKey(points: number): Promise<string> {
|
|
225
|
+
const pending = pendingRedeemKeys.get(points);
|
|
226
|
+
if (pending !== undefined) return pending;
|
|
227
|
+
if (redeemSeed === '') redeemSeed = mintRedeemSeed();
|
|
228
|
+
const key = await deriveRedeemIdempotencyKey(redeemSeed, points);
|
|
229
|
+
pendingRedeemKeys.set(points, key);
|
|
230
|
+
return key;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* THIS amount answered success: its key is dropped so the same amount can
|
|
235
|
+
* be a genuinely new conversion later, and the seed rotates lazily — the
|
|
236
|
+
* next submission mints its own. Unanswered amounts keep their keys.
|
|
237
|
+
*/
|
|
238
|
+
export function resolveRedeemKey(points: number): void {
|
|
239
|
+
pendingRedeemKeys.delete(points);
|
|
240
|
+
redeemSeed = '';
|
|
241
|
+
}
|