@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -0
  4. package/dist/account.css +1171 -0
  5. package/dist/account.d.ts +23 -0
  6. package/dist/account.js +12685 -0
  7. package/dist/account.js.map +1 -0
  8. package/dist/chunk-D6XKDVAV.js +181 -0
  9. package/dist/chunk-D6XKDVAV.js.map +1 -0
  10. package/dist/index.d.ts +3087 -0
  11. package/dist/index.js +2801 -0
  12. package/dist/index.js.map +1 -0
  13. package/eject/account/account-availability.ts +171 -0
  14. package/eject/account/account-downloads.ts +118 -0
  15. package/eject/account/account-favorites.ts +99 -0
  16. package/eject/account/account-loyalty.ts +241 -0
  17. package/eject/account/account-page.tsx +410 -0
  18. package/eject/account/account-payment-method.ts +99 -0
  19. package/eject/account/account-referrals.ts +68 -0
  20. package/eject/account/account-reseller-invite.ts +88 -0
  21. package/eject/account/account-reseller.ts +685 -0
  22. package/eject/account/account-rewards.ts +104 -0
  23. package/eject/account/account-settings.ts +181 -0
  24. package/eject/account/account-signin-link.ts +122 -0
  25. package/eject/account/account-subscriptions.ts +130 -0
  26. package/eject/account/account-support.ts +155 -0
  27. package/eject/account/account-warranties.ts +118 -0
  28. package/eject/account/customer-account.ts +93 -0
  29. package/eject/account/dashboard.tsx +139 -0
  30. package/eject/account/downloads.tsx +343 -0
  31. package/eject/account/favorites.tsx +201 -0
  32. package/eject/account/i18n.ts +175 -0
  33. package/eject/account/icons.tsx +218 -0
  34. package/eject/account/login.tsx +253 -0
  35. package/eject/account/messages/ar.json +464 -0
  36. package/eject/account/messages/de.json +464 -0
  37. package/eject/account/messages/en.json +464 -0
  38. package/eject/account/messages/es.json +464 -0
  39. package/eject/account/messages/fi.json +464 -0
  40. package/eject/account/messages/fr.json +464 -0
  41. package/eject/account/messages/hi.json +464 -0
  42. package/eject/account/messages/it.json +464 -0
  43. package/eject/account/messages/lt.json +464 -0
  44. package/eject/account/messages/nl.json +464 -0
  45. package/eject/account/messages/pl.json +464 -0
  46. package/eject/account/messages/pt.json +464 -0
  47. package/eject/account/messages/ru.json +464 -0
  48. package/eject/account/messages/tr.json +464 -0
  49. package/eject/account/messages/uk.json +464 -0
  50. package/eject/account/messages/zh.json +464 -0
  51. package/eject/account/orders.tsx +791 -0
  52. package/eject/account/overview.tsx +147 -0
  53. package/eject/account/referrals.tsx +327 -0
  54. package/eject/account/reseller.tsx +1365 -0
  55. package/eject/account/rewards.tsx +641 -0
  56. package/eject/account/settings.tsx +699 -0
  57. package/eject/account/shared.tsx +398 -0
  58. package/eject/account/subscriptions.tsx +421 -0
  59. package/eject/account/support.tsx +453 -0
  60. 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
+ }