@wix/himalaya-cli 0.1632.0 → 0.1634.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 (95) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/wix-payments-contract.d.ts +4 -14
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/wix-payments-wire.d.ts +2 -1
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/wix-payments.d.ts +1 -2
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/apple-iap-contract.d.ts +26 -13
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/apple-iap-core.d.ts +0 -2
  6. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/checkout-source.d.ts +3 -10
  7. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/commerce-core.d.ts +0 -2
  8. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/iap-order-backend.d.ts +0 -3
  9. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/iap-verticals/stores.d.ts +2 -2
  10. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/index.d.ts +2 -2
  11. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/loyalty/src/index.d.ts +64 -0
  12. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/members-feed-viewer/src/index.d.ts +10 -6
  13. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/members-v1/src/index.d.ts +36 -0
  14. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap-checkout.mjs +13 -45
  15. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap.mjs +2 -4
  16. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/screens.mjs +7 -11
  17. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-loyalty.mjs +39 -0
  18. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-members-feed-viewer.mjs +8 -7
  19. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-members.mjs +29 -0
  20. package/dist/browser-plane/assets.json +166 -0
  21. package/dist/browser-plane/host.html +156 -0
  22. package/dist/browser-plane/package-reader.js +4 -0
  23. package/dist/browser-plane/preview-deferred/368faca44fb9f7207e4af784e94750b1ee59c1098b8689e0f4a918926f244fb5.bin +0 -0
  24. package/dist/browser-plane/preview-deferred/37c179b4fefb63ea9637322b84583a2df53aa226c150294eea1675c14b1b8e29.bin +0 -0
  25. package/dist/browser-plane/preview-deferred/4ecf270136af928612c76fe4ebade91d8156975e5ea4c0cf9225501dd1788e02.bin +0 -0
  26. package/dist/browser-plane/preview-deferred/521a0e528916d678d7588b2cddf3dadd3bb959a6d62426382f61c77a82017e35.bin +1 -0
  27. package/dist/browser-plane/preview-deferred/62e2fe4718129f36ecdb0ae4de385bd6b81ecc876146447167c6c140fad699a9.bin +0 -0
  28. package/dist/browser-plane/preview-deferred/642feab8a48ddf6c7a89d7c2a7c0edfa0e6e773f671287e460e222703563191e.bin +0 -0
  29. package/dist/browser-plane/preview-deferred/97ed8f1a6772abe26e3d8af5cecfc4b02faa94497d585b0398b5053b739a70c1.bin +0 -0
  30. package/dist/browser-plane/preview-deferred/a33cfe728c584fdba4fcc1fd54bcdc2f9f2f13889ddbb5b2bd1d0f8cbe49b84e.bin +28 -0
  31. package/dist/browser-plane/preview-deferred/bf4c16a2774edd38a921edf3099469866cd74c094fd596f3fa4e81f54a04dfaa.bin +0 -0
  32. package/dist/browser-plane/preview-deferred/c3457ce58c96b65f303fec0ddf5f06733004eaf9e21e62302b155ec63a7765f7.bin +56 -0
  33. package/dist/browser-plane/preview-deferred/d7598e12c5dbef095ff8272cfc55da0250bd07fbdecbac8a530b9b277872a134.bin +0 -0
  34. package/dist/browser-plane/preview-deferred/dced97a77624bc5f8b1ae65850360908297f4588e09feb6f22792d29dc7e765b.bin +0 -0
  35. package/dist/browser-plane/preview-deferred/dee56f09fd3efc127c1ea95557edc74110c1460a46287da36e0af25efd341ef5.bin +0 -0
  36. package/dist/browser-plane/preview-deferred/ec4c7d63772d980d6e147b2df6215fd2737e4b04cebe0cf7e11dc695fb63cdcf.bin +0 -0
  37. package/dist/browser-plane/sw.js +13 -0
  38. package/dist/browser-plane/t5-frame.html +350 -0
  39. package/dist/browser-plane/t5-worker-frame.html +350 -0
  40. package/dist/cli.mjs +6499 -5089
  41. package/dist/preview-assets.generated.bin +0 -0
  42. package/fixtures/wix-canned.json +34 -1
  43. package/guides/getting-started.md +2 -1
  44. package/guides/himi-json-output.md +8 -0
  45. package/guides/himi-skill.md +2 -1
  46. package/guides/iap-screen-adoption.md +4 -4
  47. package/guides/importable-sdks.md +1 -1
  48. package/guides/one-app-section-mappings.md +3 -3
  49. package/guides/one-app-to-himi-app.md +81 -5
  50. package/guides/sdk-reference.json +8862 -4388
  51. package/guides/skill.md +11 -1
  52. package/package.json +3 -3
  53. package/templates/beauty-salon/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  54. package/templates/beauty-salon/tier5-src/himi-store-product/index.ts +1 -1
  55. package/templates/branded-lite/config.ts +7 -0
  56. package/templates/branded-lite/dev/index.ts +3340 -1890
  57. package/templates/branded-lite/himalaya.content.json +3 -0
  58. package/templates/branded-lite/l10n/en.json +8 -4
  59. package/templates/branded-lite/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  60. package/templates/branded-lite/tier5-src/_shared/business-chat-history.ts +4 -1
  61. package/templates/branded-lite/tier5-src/_shared/business-chat-presenter.ts +2 -7
  62. package/templates/branded-lite/tier5-src/_shared/header-badges.ts +1 -1
  63. package/templates/branded-lite/tier5-src/_shared/inbox-rooms.ts +68 -0
  64. package/templates/branded-lite/tier5-src/_shared/loyalty-offerings.ts +73 -0
  65. package/templates/branded-lite/tier5-src/_shared/member-activity.ts +34 -0
  66. package/templates/branded-lite/tier5-src/_shared/member-row-extras.ts +167 -0
  67. package/templates/branded-lite/tier5-src/_shared/notification-target.ts +141 -0
  68. package/templates/branded-lite/tier5-src/_shared/source-badges.ts +6 -4
  69. package/templates/branded-lite/tier5-src/_shared/wix-member-links.ts +114 -0
  70. package/templates/branded-lite/tier5-src/business-chat/index.ts +114 -59
  71. package/templates/branded-lite/tier5-src/himi-branded-lite-inbox/index.ts +83 -0
  72. package/templates/branded-lite/tier5-src/himi-branded-lite-loyalty/earning.ts +148 -0
  73. package/templates/branded-lite/tier5-src/himi-branded-lite-loyalty/index.ts +57 -31
  74. package/templates/branded-lite/tier5-src/himi-branded-lite-loyalty-rewards/index.ts +208 -0
  75. package/templates/branded-lite/tier5-src/himi-branded-lite-notifications/index.ts +62 -7
  76. package/templates/branded-lite/tier5-src/himi-store-product/index.ts +1 -1
  77. package/templates/branded-lite/tier5-src/wix-spaces-members/index.ts +60 -12
  78. package/templates/community-club/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  79. package/templates/events-meetups/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  80. package/templates/gym-fitness/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  81. package/templates/gym-fitness/tier5-src/himi-store-product/index.ts +1 -1
  82. package/templates/nonprofit-cause/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  83. package/templates/podcast-creator/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  84. package/templates/portfolio-creative/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  85. package/templates/portfolio-creative/tier5-src/himi-store-product/index.ts +1 -1
  86. package/templates/restaurant/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  87. package/templates/retail-store/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  88. package/templates/retail-store/tier5-src/himi-store-product/index.ts +1 -1
  89. package/templates/salon-spa/dev/index.ts +112 -70
  90. package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +1 -0
  91. package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +4 -1
  92. package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +2 -7
  93. package/templates/salon-spa/tier5-src/_shared/inbox-rooms.ts +68 -0
  94. package/templates/salon-spa/tier5-src/business-chat/index.ts +114 -59
  95. package/templates/yoga-studio/tier5-src/_shared/branded-lite-hosted.ts +1 -0
@@ -0,0 +1,148 @@
1
+ import { getMyMember, updateMember } from "@himalaya/wix-members";
2
+ import { listMyLoyaltyFollowedChannels, followMyLoyaltyChannel, type WixLoyaltyProgram, type WixLoyaltyEarningRule } from "@himalaya/wix-loyalty";
3
+ import { open } from "@himalaya/open-url";
4
+ import { navigate } from "@himalaya/state";
5
+ import { memberAccess, assertMemberAccess, type MemberAccess } from "../_shared/member-access.js";
6
+ import { copyParam } from "../_shared/himi-copy.js";
7
+
8
+ export interface SocialRow { id: string; name: string; label: string; followed: boolean }
9
+ export interface EarningState {
10
+ hideBirthday: boolean; birthdayLabel: string; birthdayDraft: number; birthdayPicked: boolean; birthdayOpen: boolean;
11
+ socialChannels: SocialRow[]; earnBusy: boolean; earnMessage: string;
12
+ }
13
+ export const EARNING_EMPTY: EarningState = { hideBirthday: true, birthdayLabel: "", birthdayDraft: 0, birthdayPicked: false, birthdayOpen: false, socialChannels: [], earnBusy: false, earnMessage: "" };
14
+ const business = () => (globalThis as { himalaya?: { input?: Record<string, unknown> } }).himalaya?.input?.__himiPreferenceBusiness;
15
+ const obj = (v: unknown): Record<string, unknown> => v && typeof v === "object" ? v as Record<string, unknown> : {};
16
+ const HOSTS: Record<string, string> = { FACEBOOK: "facebook.com", INSTAGRAM: "instagram.com", LINKEDIN: "linkedin.com", TIKTOK: "tiktok.com", X: "x.com" };
17
+ /** Engine-safe URL check; never hand a program-supplied scheme or credentials to open_url. */
18
+ export function validSocialUrl(type: string, url: string): boolean {
19
+ const host = /^https:\/\/([^/?#]+)(?:[/?#]|$)/i.exec(url)?.[1]?.toLowerCase();
20
+ return !!host && !!HOSTS[type] && (host === HOSTS[type] || host === `www.${HOSTS[type]}` || host === `m.${HOSTS[type]}`);
21
+ }
22
+ export function birthdayRule(rule: WixLoyaltyEarningRule): boolean {
23
+ return rule.status === "ACTIVE" && rule.triggerActivityType === "segments-contact_enters_segment" && rule.metadata?.ruleTypeTag?.ruleType === "BIRTHDAY";
24
+ }
25
+ export function birthdateAt(value: number): string | undefined {
26
+ if (!Number.isFinite(value)) return undefined;
27
+ const date = new Date(value * 1000), today = new Date();
28
+ if (!Number.isFinite(date.getTime()) || date.getFullYear() < 1900 || date > today) return undefined;
29
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
30
+ }
31
+
32
+ // Private controller state never hydrates. Every network completion rechecks the
33
+ // authenticated member AND business before publishing, including background writes.
34
+ export function createEarningController<S extends EarningState>(publish: () => Promise<unknown>) {
35
+ let revision = 0, busy = false;
36
+ let context: { member: MemberAccess; site: unknown; id: string; birthday: boolean; date: string; channels: { type: string; url: string; followed: boolean }[] } | undefined;
37
+ let outcome: { version: number; message: string; date?: string } | undefined;
38
+ const check = async (c: NonNullable<typeof context>) => {
39
+ await assertMemberAccess(c.member);
40
+ if (business() !== c.site || context !== c) throw new Error("Member access changed");
41
+ };
42
+ const view = (): EarningState => ({ ...EARNING_EMPTY, earnBusy: busy, hideBirthday: !context?.birthday || (!!context.member.email && !context.id),
43
+ birthdayLabel: copyParam(context?.date ? "loyalty.editBirthday" : "loyalty.addBirthday", context?.date ? "Edit birthday" : "Add birthday"),
44
+ socialChannels: context?.channels.map(c => ({ id: c.type, name: c.type, followed: c.followed,
45
+ label: copyParam(c.followed ? "loyalty.followed" : "loyalty.follow", c.followed ? "Followed" : "Follow") })) ?? [] });
46
+ const signIn = (s: S) => navigate(s, { screenId: "himi_sign_in", args: { returnTo: "himi_loyalty" } });
47
+ async function write(c: NonNullable<typeof context>, version: number, task: () => Promise<string | undefined>) {
48
+ let message = copyParam("loyalty.earnSaved", "Saved. Your points will update when the earning rule runs."), date: string | undefined;
49
+ try { await check(c); date = await task(); await check(c); }
50
+ catch { message = copyParam("loyalty.earnFailed", "Couldn't confirm this change. Refresh before trying again."); }
51
+ finally { busy = false; }
52
+ if (context !== c || version !== revision) { await publish(); return; }
53
+ try { await check(c); outcome = { version, message, date }; await publish(); }
54
+ catch { outcome = undefined; await publish(); }
55
+ }
56
+ return {
57
+ clear() { revision++; context = undefined; outcome = undefined; },
58
+ async load(member: MemberAccess, program: WixLoyaltyProgram, rules: WixLoyaltyEarningRule[]): Promise<EarningState> {
59
+ const version = ++revision;
60
+ const birthday = rules.some(birthdayRule);
61
+ const channels = rules.some(r => r.status === "ACTIVE" && r.triggerActivityType === "wix_loyalty-social_media_channel_followed")
62
+ ? (program.socialMediaSettings?.channels ?? []).filter(c => c.enabled === true && validSocialUrl(c.type ?? "", c.url ?? ""))
63
+ .map(c => ({ type: c.type!, url: c.url!, followed: false })) : [];
64
+ const c = { member, site: business(), id: "", birthday, date: "", channels };
65
+ context = c; outcome = undefined;
66
+ if (!member.email || (!birthday && !channels.length)) return view();
67
+ try {
68
+ const [profile, followed] = await Promise.allSettled([
69
+ birthday ? getMyMember({ fieldsets: ["FULL"] }) : Promise.resolve(undefined),
70
+ channels.length ? listMyLoyaltyFollowedChannels() : Promise.resolve(undefined),
71
+ ]);
72
+ await check(c);
73
+ if (version !== revision) return { ...EARNING_EMPTY };
74
+ let failed = false;
75
+ if (birthday) {
76
+ if (profile.status === "fulfilled" && profile.value?.member?.id) {
77
+ c.id = profile.value.member.id;
78
+ c.date = profile.value.member.contact?.birthdate ?? "";
79
+ } else { c.id = ""; failed = true; }
80
+ }
81
+ if (channels.length) {
82
+ const rows = followed.status === "fulfilled" ? followed.value?.followedChannels : undefined;
83
+ if (followed.status === "fulfilled" && (rows === undefined || Array.isArray(rows))) {
84
+ const known = new Set(rows?.map(row => row.channel));
85
+ c.channels = channels.map(channel => ({ ...channel, followed: known.has(channel.type) }));
86
+ } else { c.channels = []; failed = true; }
87
+ }
88
+ return { ...view(), earnMessage: failed ? copyParam("loyalty.earnLoadError", "Your earning actions couldn't be loaded. Refresh to try again.") : "" };
89
+ } catch {
90
+ if (context !== c || version !== revision) return { ...EARNING_EMPTY };
91
+ // A failed private read must not pretend the member has never followed.
92
+ c.channels = []; c.id = "";
93
+ return { ...view(), earnMessage: copyParam("loyalty.earnLoadError", "Your earning actions couldn't be loaded. Refresh to try again.") };
94
+ }
95
+ },
96
+ async openBirthday(s: S): Promise<S> {
97
+ if (!(await memberAccess()).email) return signIn(s);
98
+ const c = context;
99
+ if (!c?.birthday || !c.id || busy) return s;
100
+ await check(c);
101
+ const parsed = c.date ? new Date(`${c.date}T12:00:00`).getTime() / 1000 : Date.now() / 1000;
102
+ return { ...s, birthdayOpen: true, birthdayPicked: false, birthdayDraft: Number.isFinite(parsed) ? parsed : 0, earnMessage: "" };
103
+ },
104
+ draftBirthday(s: S, arg: unknown): S { const value = Number(obj(arg).value); return busy || !birthdateAt(value) ? s : { ...s, birthdayDraft: value, birthdayPicked: true }; },
105
+ closeBirthday(s: S): S { return busy ? s : { ...s, birthdayOpen: false }; },
106
+ saveBirthday(s: S): S {
107
+ const c = context, date = birthdateAt(s.birthdayDraft);
108
+ if (busy || !s.birthdayOpen || !c?.id || !c.birthday) return s;
109
+ if (!date || !s.birthdayPicked) return { ...s, earnMessage: copyParam("loyalty.birthdayInvalid", "Choose a birthday in the past.") };
110
+ busy = true;
111
+ void write(c, revision, async () => {
112
+ const result = await updateMember(c.id, { member: { contact: { birthdate: date } } });
113
+ if (result.member?.contact?.birthdate !== date) throw new Error("Birthday update was not confirmed");
114
+ return date;
115
+ });
116
+ return { ...s, earnBusy: true, earnMessage: copyParam("loyalty.saving", "Saving…") };
117
+ },
118
+ async followChannel(s: S, arg: unknown): Promise<S> {
119
+ if (!(await memberAccess()).email) return signIn(s);
120
+ const c = context, channel = c?.channels.find(row => row.type === obj(arg).id);
121
+ if (!c || !channel || busy) return s;
122
+ await check(c);
123
+ if (busy) return s;
124
+ busy = true;
125
+ void write(c, revision, async () => {
126
+ if (!channel.followed) {
127
+ const result = await followMyLoyaltyChannel(channel.type);
128
+ if (result.followedChannel?.channel !== channel.type) throw new Error("Follow was not confirmed");
129
+ await check(c); channel.followed = true;
130
+ }
131
+ const opened = await open(channel.url);
132
+ if (!opened.opened) throw new Error("Could not open channel");
133
+ return undefined;
134
+ });
135
+ return { ...s, earnBusy: true, earnMessage: copyParam("loyalty.saving", "Saving…") };
136
+ },
137
+ async finishEarning(s: S): Promise<S> {
138
+ if (context) {
139
+ try { await check(context); }
140
+ catch { context = undefined; outcome = undefined; return { ...s, ...EARNING_EMPTY }; }
141
+ }
142
+ if (!outcome || outcome.version !== revision || !context) return { ...s, earnBusy: busy };
143
+ if (outcome.date) context.date = outcome.date;
144
+ const message = outcome.message; outcome = undefined;
145
+ return { ...s, ...view(), earnMessage: message };
146
+ },
147
+ };
148
+ }
@@ -9,13 +9,20 @@ import { calendarDay, formatCurrency, formatDateMedium, formatNumber } from "../
9
9
  import { appLocale, isSingular } from "../_shared/app-locale.js";
10
10
  import { memberAccess, assertMemberAccess } from "../_shared/member-access.js";
11
11
  import {
12
- getLoyaltyProgram, listLoyaltyRewards, listLoyaltyEarningRules, getCurrentMemberLoyaltyAccount, getLoyaltyTiersProgram, listMyLoyaltyCoupons,
12
+ getLoyaltyProgram, listLoyaltyRewards, listLoyaltyEarningRules, getCurrentMemberLoyaltyAccount, getLoyaltyTiersProgram, listMyLoyaltyCoupons, listMyReferralRewards,
13
13
  type WixLoyaltyReward, type WixLoyaltyEarningRule, type WixLoyaltyProgram, type WixLoyaltyAccount, type WixLoyaltyTiersProgram,
14
- type WixLoyaltyCoupon, type WixLoyaltyRewardConfig,
14
+ type WixLoyaltyCoupon, type WixReferralReward,
15
15
  } from "@himalaya/wix-loyalty";
16
16
 
17
+ import { buildOfferings, visibleOfferings, offeringRewardLabel, memberRewardCost, OFFERING_TIER_LIMIT, type OfferingGroup } from "../_shared/loyalty-offerings.js";
18
+
19
+ import { createEarningController, EARNING_EMPTY, type EarningState } from "./earning.js";
20
+ import { withOnlineWrites } from "../_shared/online-actions.js";
21
+
17
22
  interface Row { id: string; name: string; subtitle: string }
18
- interface S extends LoadFlags {
23
+ interface S extends LoadFlags, EarningState {
24
+ offeringGroups: OfferingGroup[]; offeringsExpanded: boolean;
25
+ hideTierOfferings: boolean; hideBaseOffering: boolean; hideShowAllTiers: boolean; tierError: string;
19
26
  programName: string; unit: string; currency: string;
20
27
  rewards: Row[]; earningRules: Row[]; emptyText: string; earningEmptyText: string;
21
28
  hideCatalog: boolean; hideEarningHeading: boolean; inactiveText: string;
@@ -29,12 +36,13 @@ interface S extends LoadFlags {
29
36
  /** "Rewards available (N)"; hidden when N is 0 or cannot be counted honestly. */
30
37
  memberRewards: string; hideMemberRewards: boolean;
31
38
  }
32
- const PRIVATE_EMPTY = {
39
+ const PRIVATE_EMPTY = { ...EARNING_EMPTY,
33
40
  memberPoints: "", memberMessage: "", hideMemberCard: true, hideMemberSignIn: true, hideMemberRetry: true,
34
41
  memberTier: "", memberExpiry: "", memberExpiryUrgent: "", memberRewards: "", hideMemberRewards: true,
35
42
  };
36
43
  const PRIVATE_KEYS = Object.keys(PRIVATE_EMPTY) as (keyof typeof PRIVATE_EMPTY)[];
37
- const INITIAL: S = { ...LOADING, ...PRIVATE_EMPTY, programName: "", unit: "", currency: "", rewards: [], earningRules: [], emptyText: "", earningEmptyText: "", hideCatalog: false, hideEarningHeading: true, inactiveText: "", rewardError: "", earningError: "", hideRewardError: true, hideEarningError: true, hideRewardRows: false };
44
+ const OFFERING_EMPTY = { offeringGroups: [] as OfferingGroup[], offeringsExpanded: false, hideTierOfferings: true, hideBaseOffering: false, hideShowAllTiers: true, tierError: "" };
45
+ const INITIAL: S = { ...OFFERING_EMPTY, ...LOADING, ...PRIVATE_EMPTY, programName: "", unit: "", currency: "", rewards: [], earningRules: [], emptyText: "", earningEmptyText: "", hideCatalog: false, hideEarningHeading: true, inactiveText: "", rewardError: "", earningError: "", hideRewardError: true, hideEarningError: true, hideRewardRows: false };
38
46
 
39
47
  function amount(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; }
40
48
  function baseConfig<T extends { tierId?: string }>(configs: T[] | undefined): T | undefined {
@@ -73,22 +81,13 @@ function currencyFrom(s: S, arg: unknown): string {
73
81
  const raw = (arg as { currency?: unknown }).currency;
74
82
  return typeof raw === "string" && /^[A-Z]{3}$/.test(raw) ? raw : "";
75
83
  }
76
- /** What `reward` costs a member on `tierId` (undefined = the base tier): its row for that tier,
77
- * else the base row, else the legacy flat `requiredPoints`. Undefined when nothing prices it. */
78
- function memberCost(reward: WixLoyaltyReward, tierId: string | undefined): number | undefined {
79
- const configs: WixLoyaltyRewardConfig[] | undefined = reward.discountAmount?.configsByTier ?? reward.couponReward?.fixedAmount?.configsByTier
80
- ?? reward.couponReward?.percentage?.configsByTier ?? reward.couponReward?.freeShipping?.configsByTier;
81
- if (!configs?.length) return amount(reward.requiredPoints) ? reward.requiredPoints : undefined;
82
- const row = (tierId ? configs.find(config => config.tierId === tierId) : undefined) ?? baseConfig(configs);
83
- return amount(row?.costInPoints) ? row.costInPoints : undefined;
84
- }
85
84
  /** The one-app `calculateAvailableRewardsToClaim`: active rewards the balance covers on the
86
85
  * member's tier (an SPI discount prices itself in text, not points, so it is left out), plus
87
86
  * claimed coupons that are still ACTIVE and not deleted. */
88
87
  export function countAvailableRewards(rewards: WixLoyaltyReward[], coupons: WixLoyaltyCoupon[], balance: number, tierId: string | undefined): number {
89
88
  const claimable = rewards.filter(reward => {
90
89
  if (reward.active !== true || reward.type === "SPI_DISCOUNT_AMOUNT") return false;
91
- const cost = memberCost(reward, tierId);
90
+ const cost = memberRewardCost(reward, tierId);
92
91
  return cost !== undefined && cost <= balance;
93
92
  }).length;
94
93
  return claimable + coupons.filter(coupon => coupon.status === "ACTIVE" && !coupon.couponReference?.deleted).length;
@@ -128,11 +127,12 @@ const settle = <T>(read: Promise<T>): Promise<Settled<T>> => read.then(value =>
128
127
  /** The status lines for a member whose balance is known. A failed tiers read only drops the tier;
129
128
  * a failed coupon or rewards read drops the count, because a partial total would understate it. */
130
129
  function statusLines(program: WixLoyaltyProgram, account: WixLoyaltyAccount, balance: number, tiers: Settled<WixLoyaltyTiersProgram>,
131
- coupons: Settled<{ loyaltyCoupons?: WixLoyaltyCoupon[] }>, rewards: WixLoyaltyReward[] | undefined) {
130
+ coupons: Settled<{ loyaltyCoupons?: WixLoyaltyCoupon[] }>, referrals: Settled<{ referralRewards: WixReferralReward[] }>, rewards: WixLoyaltyReward[] | undefined) {
132
131
  const tierId = account.tier?.id || undefined;
133
132
  const tier = tiers.failed ? "" : currentTierName(tiers.value, tierId);
134
133
  const [expiry, urgent] = expirySentence(program, account);
135
- const count = coupons.failed || !rewards ? 0 : countAvailableRewards(rewards, coupons.value?.loyaltyCoupons ?? [], balance, tierId);
134
+ const referralCount = referrals.value?.referralRewards.filter(r => r.rewardType === "COUPON" && r.coupon?.status === "ACTIVE").length ?? 0;
135
+ const count = coupons.failed || referrals.failed || !rewards ? 0 : countAvailableRewards(rewards, coupons.value?.loyaltyCoupons ?? [], balance, tierId) + referralCount;
136
136
  return {
137
137
  memberTier: tier ? copyTemplate("loyalty.memberTier", "{tier} tier", { tier }) : "",
138
138
  memberExpiry: urgent ? "" : expiry, memberExpiryUrgent: urgent ? expiry : "",
@@ -145,13 +145,26 @@ const COPY: LoadCopy<Row> = {
145
145
  sample: () => [{ id: "sample-reward", name: copyParam("loyalty.sampleReward", "5% off your next order"), subtitle: points(500, "") }],
146
146
  };
147
147
 
148
- const svc = defineService<S, { prepare: (s: S) => S; load: (s: S, arg?: unknown) => Promise<S> }>("himiBrandedLoyalty", {
148
+ let loadRevision = 0;
149
+ let allOfferings: OfferingGroup[] = [];
150
+ const earning: ReturnType<typeof createEarningController<S>> = createEarningController<S>((): Promise<unknown> => svc.finishEarning());
151
+ const register = withOnlineWrites(defineService, ["saveBirthday", "followChannel"], { messageFields: ["earnMessage"] });
152
+ const svc = register<S, { prepare: (s: S) => S; load: (s: S, arg?: unknown) => Promise<S>; showAllTiers: (s: S) => S;
153
+ openBirthday: (s: S) => Promise<S>; draftBirthday: (s: S, arg?: unknown) => S; closeBirthday: (s: S) => S;
154
+ saveBirthday: (s: S) => S; followChannel: (s: S, arg?: unknown) => Promise<S>; finishEarning: (s: S) => Promise<S>;
155
+ }>("himiBrandedLoyalty", {
149
156
  state: INITIAL,
150
157
  notHydrated: PRIVATE_KEYS,
151
158
  notPersisted: PRIVATE_KEYS,
152
159
  actions: {
153
- prepare: s => ({ ...s, ...PRIVATE_EMPTY }),
160
+ showAllTiers: s => allOfferings.length ? ({ ...s, offeringsExpanded: true, offeringGroups: allOfferings, hideShowAllTiers: true }) : s,
161
+ openBirthday: s => earning.openBirthday(s), draftBirthday: (s, arg) => earning.draftBirthday(s, arg),
162
+ closeBirthday: s => earning.closeBirthday(s), saveBirthday: s => earning.saveBirthday(s),
163
+ followChannel: (s, arg) => earning.followChannel(s, arg), finishEarning: s => earning.finishEarning(s),
164
+ prepare: s => { loadRevision++; allOfferings = []; earning.clear(); return { ...s, ...PRIVATE_EMPTY }; },
154
165
  load: async (s, arg) => {
166
+ const revision = ++loadRevision;
167
+ earning.clear(); allOfferings = [];
155
168
  const currency = currencyFrom(s, arg);
156
169
  const business = businessStamp();
157
170
  try {
@@ -159,8 +172,8 @@ const svc = defineService<S, { prepare: (s: S) => S; load: (s: S, arg?: unknown)
159
172
  const program = response.loyaltyProgram ?? response.program;
160
173
  if (!program || !["ACTIVE", "DRAFT", "PAUSED"].includes(program.status ?? "")) throw new Error("Loyalty program response is unavailable");
161
174
  const customUnit = (program.pointDefinition?.customName ?? "").trim();
162
- const common = { ...s, ...PRIVATE_EMPTY, currency, programName: program.name || copyParam("loyalty.programFallback", "Rewards"), unit: customUnit || copyParam("loyalty.pointMany", "points"), inactiveText: "", rewardError: "", earningError: "", hideRewardError: true, hideEarningError: true, hideRewardRows: true, hideEarningHeading: true };
163
- if (program.status !== "ACTIVE") {
175
+ const common = { ...s, ...PRIVATE_EMPTY, ...OFFERING_EMPTY, currency, programName: program.name || copyParam("loyalty.programFallback", "Rewards"), unit: customUnit || copyParam("loyalty.pointMany", "points"), inactiveText: "", rewardError: "", earningError: "", hideRewardError: true, hideEarningError: true, hideRewardRows: true, hideEarningHeading: true };
176
+ if (program.status !== "ACTIVE" || program.premiumFeatures?.loyaltyProgram === false) {
164
177
  return { ...common, ...loaded("rewards", [], COPY), earningRules: [], earningEmptyText: "", hideCatalog: true,
165
178
  inactiveText: copyParam("loyalty.inactive", "The rewards program isn't available right now.") };
166
179
  }
@@ -169,23 +182,36 @@ const svc = defineService<S, { prepare: (s: S) => S; load: (s: S, arg?: unknown)
169
182
  const accountRead = member.email && privateStateSupported
170
183
  ? settle(getCurrentMemberLoyaltyAccount())
171
184
  : Promise.resolve({ value: undefined, failed: false });
172
- // The status lines' reads go only where the balance read goes: never for a visitor or an older host.
173
- const statusRead = member.email && privateStateSupported
174
- ? Promise.all([settle(getLoyaltyTiersProgram()), settle(listMyLoyaltyCoupons())])
175
- : Promise.resolve([{ failed: true }, { failed: true }] as [Settled<WixLoyaltyTiersProgram>, Settled<{ loyaltyCoupons?: WixLoyaltyCoupon[] }>]);
176
- const [[rewardResult, earningResult], account, [tiers, coupons]] = await Promise.all([
177
- Promise.allSettled([listLoyaltyRewards(), listLoyaltyEarningRules()]), accountRead, statusRead,
185
+ // Public tier metadata is shared by the visitor offering and the member status.
186
+ // Coupons and account details remain member-only.
187
+ const tiersRead = settle(getLoyaltyTiersProgram());
188
+ const couponsRead = member.email && privateStateSupported ? settle(listMyLoyaltyCoupons())
189
+ : Promise.resolve({ failed: true } as Settled<{ loyaltyCoupons?: WixLoyaltyCoupon[] }>);
190
+ const referralsRead = member.email && privateStateSupported ? settle(listMyReferralRewards())
191
+ : Promise.resolve({ failed: true } as Settled<{ referralRewards: WixReferralReward[] }>);
192
+ const [[rewardResult, earningResult], account, tiers, coupons, referrals] = await Promise.all([
193
+ Promise.allSettled([listLoyaltyRewards(), listLoyaltyEarningRules()]), accountRead, tiersRead, couponsRead, referralsRead,
178
194
  ]);
179
195
  const rewardResponse = rewardResult.status === "fulfilled" ? rewardResult.value : undefined;
180
196
  const earningResponse = earningResult.status === "fulfilled" ? earningResult.value : undefined;
181
197
  if (businessStamp() !== business) throw new Error("Business changed while Loyalty loaded");
182
198
  if (rewardResult.status === "rejected" && earningResult.status === "rejected") throw new Error("Loyalty catalog is unavailable");
183
199
  const rewards = (rewardResponse?.rewards ?? []).filter(reward => reward.active === true).map((reward, i) => ({
184
- id: reward.id || `reward-${i}`, name: reward.name || copyParam("loyalty.rewardFallback", "Reward"), subtitle: rewardLabel(reward, customUnit),
200
+ id: reward.id || `reward-${i}`, name: reward.name || copyParam("loyalty.rewardFallback", "Reward"), subtitle: reward.type === "SPI_DISCOUNT_AMOUNT" ? offeringRewardLabel(reward, undefined, customUnit, currency) : rewardLabel(reward, customUnit),
185
201
  }));
186
202
  const earningRules = (earningResponse?.earningRules ?? []).filter(rule => rule.status === "ACTIVE").map((rule, i) => ({
187
203
  id: rule.id || `earning-${i}`, name: rule.title || copyParam("loyalty.earnFallback", "Earn points"), subtitle: earningLabel(rule, customUnit, currency),
188
204
  }));
205
+ const tiered = !tiers.failed && tiers.value?.programSettings?.status === "ACTIVE" && program.premiumFeatures?.tiers !== false;
206
+ const allOfferingGroups = tiered ? buildOfferings(tiers.value, rewardResponse?.rewards ?? [], earningResponse?.earningRules ?? [], customUnit, currency) : [];
207
+ const offeringsExpanded = s.offeringsExpanded;
208
+ const offeringState = { offeringGroups: visibleOfferings(allOfferingGroups, offeringsExpanded), offeringsExpanded,
209
+ hideTierOfferings: !tiered, hideBaseOffering: tiered, hideShowAllTiers: !tiered || offeringsExpanded || allOfferingGroups.length <= OFFERING_TIER_LIMIT,
210
+ tierError: tiers.failed ? copyParam("loyalty.tiersError", "Tier benefits couldn't be loaded. Showing the base offering.") : "" };
211
+ if (revision !== loadRevision) return s;
212
+ allOfferings = allOfferingGroups;
213
+ const earningState = member.email && !privateStateSupported ? { ...EARNING_EMPTY } : await earning.load(member, program, earningResponse?.earningRules ?? []);
214
+ if (businessStamp() !== business) throw new Error("Business changed while Loyalty loaded");
189
215
  let privateState = { ...PRIVATE_EMPTY };
190
216
  if (member.email) {
191
217
  try {
@@ -194,7 +220,7 @@ const svc = defineService<S, { prepare: (s: S) => S; load: (s: S, arg?: unknown)
194
220
  const known = typeof balance === "number" && Number.isFinite(balance);
195
221
  privateState = { ...PRIVATE_EMPTY, hideMemberCard: false,
196
222
  ...(known && privateStateSupported && !account.failed
197
- ? statusLines(program, account.value?.account ?? {}, balance, tiers, coupons, rewardResult.status === "fulfilled" ? (rewardResponse?.rewards ?? []) : undefined) : {}),
223
+ ? statusLines(program, account.value?.account ?? {}, balance, tiers, coupons, referrals, rewardResult.status === "fulfilled" ? (rewardResponse?.rewards ?? []) : undefined) : {}),
198
224
  memberPoints: typeof balance === "number" && Number.isFinite(balance) ? points(balance, customUnit) : "",
199
225
  memberMessage: !privateStateSupported ? copyParam("loyalty.updateApp", "Update the app to see your points securely.") : account.failed ? copyParam("loyalty.pointsError", "Your points couldn't be loaded.")
200
226
  : typeof balance !== "number" || !Number.isFinite(balance) ? copyParam("loyalty.pointsUnavailable", "Your points aren't available yet.") : "",
@@ -207,7 +233,7 @@ const svc = defineService<S, { prepare: (s: S) => S; load: (s: S, arg?: unknown)
207
233
  } else {
208
234
  privateState.hideMemberSignIn = false;
209
235
  }
210
- return { ...common, ...loaded("rewards", rewards, COPY), ...privateState, earningRules, hideCatalog: false, hideEarningHeading: false,
236
+ return { ...common, ...loaded("rewards", rewards, COPY), ...privateState, ...offeringState, ...earningState, earningRules, hideCatalog: false, hideEarningHeading: false,
211
237
  rewardError: rewardResult.status === "rejected" ? copyParam("loyalty.rewardsError", "Rewards couldn't be loaded.") : "",
212
238
  earningError: earningResult.status === "rejected" ? copyParam("loyalty.earningError", "Ways to earn points couldn't be loaded.") : "",
213
239
  hideRewardError: rewardResult.status === "fulfilled", hideEarningError: earningResult.status === "fulfilled", hideRewardRows: rewardResult.status === "rejected",
@@ -215,7 +241,7 @@ const svc = defineService<S, { prepare: (s: S) => S; load: (s: S, arg?: unknown)
215
241
  earningEmptyText: earningResult.status === "rejected" || earningRules.length ? "" : copyParam("loyalty.earningEmpty", "No ways to earn points yet.") };
216
242
  } catch (error) {
217
243
  const failure = loadFailed("rewards", error, COPY);
218
- return { ...s, ...PRIVATE_EMPTY, currency, earningRules: [], earningEmptyText: "", inactiveText: "", hideCatalog: !failure.isMock, hideEarningHeading: true, rewardError: "", earningError: "", hideRewardError: true, hideEarningError: true, hideRewardRows: !failure.isMock,
244
+ return { ...s, ...PRIVATE_EMPTY, ...OFFERING_EMPTY, currency, earningRules: [], earningEmptyText: "", inactiveText: "", hideCatalog: !failure.isMock, hideEarningHeading: true, rewardError: "", earningError: "", hideRewardError: true, hideEarningError: true, hideRewardRows: !failure.isMock,
219
245
  ...failure, signInRequired: false, noSignIn: true };
220
246
  }
221
247
  },
@@ -0,0 +1,208 @@
1
+ // Member rewards use their own service, so a public offering can never hydrate a
2
+ // balance or coupon. All private state is online-only, and every completion checks
3
+ // the principal and business. Navigation parameters provide an ID, never a price.
4
+ import { defineService, navigate } from "@himalaya/state";
5
+ import { getLoyaltyProgram, listLoyaltyRewards, getCurrentMemberLoyaltyAccount, listMyLoyaltyCoupons, listMyReferralRewards, redeemMyLoyaltyReward,
6
+ type WixLoyaltyReward, type WixLoyaltyAccount, type WixLoyaltyCoupon, type WixLoyaltyProgram, type WixReferralReward } from "@himalaya/wix-loyalty";
7
+ import { memberAccess, assertMemberAccess, type MemberAccess } from "../_shared/member-access.js";
8
+ import { withOnlineWrites } from "../_shared/online-actions.js";
9
+ import { copyParam, copyTemplate, stashCopy } from "../_shared/himi-copy.js";
10
+ import { offeringRewardLabel, memberRewardCost, pointLabel } from "../_shared/loyalty-offerings.js";
11
+
12
+ interface MemberRow { id: string; name: string; subtitle: string; eligibility: string; code: string; isCoupon: boolean; notCoupon: boolean; section: string }
13
+ interface S {
14
+ hideLoading: boolean; hideContent: boolean; hideSignIn: boolean; hideError: boolean; error: string;
15
+ points: string; items: MemberRow[]; emptyText: string;
16
+ confirming: boolean; confirmation: string; selectedId: string; writeBusy: boolean; message: string;
17
+ referralError: string; resultCode: string; resultName: string;
18
+ }
19
+ const INITIAL: S = { hideLoading: false, hideContent: true, hideSignIn: true, hideError: true, error: "", points: "", items: [], emptyText: "",
20
+ referralError: "", confirming: false, confirmation: "", selectedId: "", writeBusy: false, message: "", resultCode: "", resultName: "" };
21
+ const PRIVATE_KEYS = Object.keys(INITIAL) as (keyof S)[];
22
+ const input = () => (globalThis as { himalaya?: { input?: Record<string, unknown>; readLifecycleVersion?: number } }).himalaya;
23
+ const business = () => input()?.input?.__himiPreferenceBusiness;
24
+ const object = (arg: unknown): Record<string, unknown> => arg && typeof arg === "object" ? arg as Record<string, unknown> : {};
25
+ let generation = 0, writing = false, reviewing = false;
26
+ let currency = "", restaurantScreen = "";
27
+ let context: { member: MemberAccess; business: unknown; program: WixLoyaltyProgram; account: WixLoyaltyAccount; rewards: WixLoyaltyReward[]; coupons: WixLoyaltyCoupon[]; referrals: WixReferralReward[]; referralKnown: boolean } | undefined;
28
+ let confirmation: { id: string; member: MemberAccess; business: unknown; cost: number } | undefined;
29
+ let result: { generation: number; message: string; coupon?: WixLoyaltyCoupon } | undefined;
30
+ // One confirmed write may await two independently replicated reads. Keep its
31
+ // coupon and stop further spending until both reads reflect the transaction.
32
+ let confirmed: { coupon: WixLoyaltyCoupon; balanceAfter?: number; redeemedAfter?: number } | undefined;
33
+ const pointAmount = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0;
34
+ // An ambiguous write cannot be retried by another tap or refresh in this session.
35
+ const uncertain = new Set<string>();
36
+ const MAX_UNCERTAIN_REWARDS = 64;
37
+ let uncertaintyOverflow = false;
38
+ const blocked = (id: string) => !!confirmed || uncertaintyOverflow || uncertain.has(id);
39
+ let uncertainPrincipal = "";
40
+ const stamp = (member: MemberAccess) => JSON.stringify([member.email, member.scope, business()]);
41
+ const couponReward = (reward: WixLoyaltyReward) => reward.type === "COUPON_REWARD" || reward.type === "COUPON";
42
+ // These responses confirm the write was rejected. Timeouts (including HTTP 408),
43
+ // 5xx and unclassified errors leave its outcome uncertain and must stay blocked.
44
+ function definitiveRejection(error: unknown): boolean {
45
+ const status = /\bHTTP\s+(\d{3})\b/.exec(String(error))?.[1];
46
+ return !!status && ["400", "401", "403", "404", "409", "412", "422", "428", "429"].includes(status);
47
+ }
48
+ const guard = async (c: NonNullable<typeof context>) => {
49
+ await assertMemberAccess(c.member);
50
+ if (context !== c || business() !== c.business) throw new Error("Member access changed");
51
+ };
52
+ const signInState = (): S => ({ ...INITIAL, hideLoading: true, hideSignIn: false });
53
+ function reconcile(c: NonNullable<typeof context>) {
54
+ if (!confirmed) return;
55
+ const points = c.account.points;
56
+ const debited = (confirmed.balanceAfter !== undefined && pointAmount(points?.balance) && points.balance <= confirmed.balanceAfter)
57
+ || (confirmed.redeemedAfter !== undefined && pointAmount(points?.redeemed) && points.redeemed >= confirmed.redeemedAfter);
58
+ if (debited && c.coupons.some(coupon => coupon.id === confirmed!.coupon.id)) confirmed = undefined;
59
+ }
60
+ function rows(c: NonNullable<typeof context>): Pick<S, "items" | "points" | "emptyText" | "referralError"> {
61
+ const unit = c.program.pointDefinition?.customName ?? "";
62
+ const balance = c.account.points?.balance;
63
+ const ordered = c.rewards.filter(r => r.active === true && !!r.id).map((reward, index) => {
64
+ const cost = memberRewardCost(reward, c.account.tier?.id);
65
+ return { reward, index, available: couponReward(reward) && !blocked(reward.id) && cost !== undefined && typeof balance === "number" && Number.isFinite(balance) && balance >= cost ? 1 : 0 };
66
+ }).sort((a, b) => b.available - a.available || a.index - b.index);
67
+ const rewards = ordered.map(({ reward }, index) => {
68
+ const cost = memberRewardCost(reward, c.account.tier?.id);
69
+ const eligibility = confirmed ? copyParam("loyalty.redeemedRefreshFailed", "Reward redeemed. Your balance couldn't be refreshed; refresh before redeeming again.")
70
+ : blocked(reward.id) ? copyParam("loyalty.redemptionUncertain", "Check your coupons: this redemption could not be confirmed.")
71
+ : reward.type === "DISCOUNT_AMOUNT" ? copyParam("loyalty.orderToRedeem", "Use this reward when placing an order.")
72
+ : !couponReward(reward) ? copyParam("loyalty.checkoutReward", "Use this reward at checkout.")
73
+ : cost === undefined ? copyParam("loyalty.notOnTier", "This reward is not available on your tier.")
74
+ : typeof balance !== "number" || !Number.isFinite(balance) ? copyParam("loyalty.pointsUnavailable", "Your points aren't available yet.")
75
+ : balance < cost ? copyTemplate("loyalty.pointsNeeded", "{points} more needed", { points: pointLabel(cost - balance, unit) })
76
+ : copyParam("loyalty.readyToRedeem", "Available to redeem");
77
+ return { id: reward.id, code: "", isCoupon: false, notCoupon: true, section: index === 0 ? copyParam("loyalty.rewardsHeading", "Rewards") : "", name: reward.name || copyParam("loyalty.rewardFallback", "Reward"), subtitle: offeringRewardLabel(reward, c.account.tier?.id, unit, currency), eligibility };
78
+ });
79
+ const knownCoupons = confirmed && !c.coupons.some(coupon => coupon.id === confirmed!.coupon.id) ? [...c.coupons, confirmed.coupon] : c.coupons;
80
+ const coupons = knownCoupons.filter(c => c.status === "ACTIVE" && !c.couponReference?.deleted && !!c.id && !!c.couponReference?.code)
81
+ .map(c => ({ id: c.id!, name: c.rewardName || copyParam("loyalty.rewardFallback", "Reward"), code: c.couponReference!.code! }));
82
+ const referralCoupons = c.referrals.filter(r => r.rewardType === "COUPON" && r.coupon?.status === "ACTIVE" && !!r.id && !!r.coupon.code)
83
+ .map(r => ({ id: `referral:${r.id}`, name: r.coupon!.couponSpecification?.name || copyParam("loyalty.referralReward", "Referral reward"), code: r.coupon!.code! }));
84
+ const allCoupons = [...coupons, ...referralCoupons];
85
+ const items: MemberRow[] = [...allCoupons.map((coupon, index) => ({ ...coupon, id: `coupon:${coupon.id}`, isCoupon: true, notCoupon: false, subtitle: "", eligibility: "", section: index === 0 ? copyParam("loyalty.claimedRewards", "Your coupons") : "" })), ...rewards];
86
+ return { items, referralError: c.referralKnown ? "" : copyParam("loyalty.referralsError", "Referral rewards couldn't be verified. Refresh to try again."), points: !confirmed && typeof balance === "number" && Number.isFinite(balance) ? pointLabel(balance, unit) : "",
87
+ emptyText: rewards.length || allCoupons.length ? "" : copyParam("loyalty.emptyText", "No rewards to redeem yet.") };
88
+ }
89
+
90
+ async function redeem(c: NonNullable<typeof context>, selected: NonNullable<typeof confirmation>, version: number) {
91
+ let coupon: WixLoyaltyCoupon | undefined;
92
+ let message = "";
93
+ try {
94
+ await guard(c);
95
+ const before = c.account.points;
96
+ // The server validates current member, active reward, tier and cost atomically.
97
+ const response = await redeemMyLoyaltyReward(selected.id);
98
+ await guard(c);
99
+ coupon = response.coupon;
100
+ if (!coupon?.id || !coupon.couponReference?.code) throw new Error("Redemption was not confirmed");
101
+ confirmed = { coupon, balanceAfter: pointAmount(before?.balance) ? Math.max(0, before.balance - selected.cost) : undefined,
102
+ redeemedAfter: pointAmount(before?.redeemed) ? before.redeemed + selected.cost : undefined };
103
+ message = copyParam("loyalty.redeemed", "Reward redeemed. Use this coupon at checkout.");
104
+ const [account, coupons] = await Promise.allSettled([getCurrentMemberLoyaltyAccount(), listMyLoyaltyCoupons()]);
105
+ await guard(c);
106
+ // A failed follow-up read cannot erase the successful coupon or retain a stale spendable balance.
107
+ c.account = account.status === "fulfilled" ? account.value.account ?? {} : {};
108
+ c.coupons = coupons.status === "fulfilled" ? coupons.value.loyaltyCoupons ?? [] : [];
109
+ reconcile(c);
110
+ if (confirmed || account.status === "rejected" || coupons.status === "rejected") message = copyParam("loyalty.redeemedRefreshFailed", "Reward redeemed. Your balance couldn't be refreshed; refresh before redeeming again.");
111
+ } catch (error) {
112
+ if (definitiveRejection(error)) {
113
+ c.account = {}; // The eligibility snapshot may have changed since confirmation.
114
+ message = copyParam("loyalty.cannotRedeem", "This reward can't be redeemed with your current points and tier.");
115
+ } else {
116
+ if (uncertain.size < MAX_UNCERTAIN_REWARDS) uncertain.add(selected.id);
117
+ else uncertaintyOverflow = true; // Fail closed instead of evicting an ambiguous operation.
118
+ message = copyParam("loyalty.redemptionFailed", "Couldn't confirm redemption. Check your coupons and balance before redeeming again. This action won't be retried automatically.");
119
+ }
120
+ } finally { writing = false; }
121
+ if (version !== generation || context !== c) { await svc.load(); return; }
122
+ try { await guard(c); result = { generation: version, message, coupon }; await svc.finishRedemption(); }
123
+ catch { await svc.load(); }
124
+ }
125
+
126
+ const register = withOnlineWrites(defineService, ["confirmRedemption"], { messageFields: ["message"] });
127
+ const svc = register<S, {
128
+ prepare: (s: S) => S; load: (s: S, arg?: unknown) => Promise<S | undefined>;
129
+ reviewReward: (s: S, arg?: unknown) => Promise<S>; cancelRedemption: (s: S) => S;
130
+ confirmRedemption: (s: S) => S; finishRedemption: (s: S) => S;
131
+ }>("himiBrandedLoyaltyRewards", {
132
+ state: INITIAL, notHydrated: PRIVATE_KEYS, notPersisted: PRIVATE_KEYS, perVisit: PRIVATE_KEYS,
133
+ actions: {
134
+ prepare: () => { generation++; context = undefined; confirmation = undefined; result = undefined; return { ...INITIAL }; },
135
+ load: async (s, arg) => {
136
+ stashCopy(arg);
137
+ if (writing) return s;
138
+ const version = ++generation; context = undefined; confirmation = undefined; result = undefined;
139
+ const args = object(arg);
140
+ if ("currency" in args) currency = typeof args.currency === "string" && /^[A-Z]{3}$/.test(args.currency) ? args.currency : "";
141
+ if ("restaurantScreen" in args) restaurantScreen = args.restaurantScreen === "himi_menu" ? "himi_menu" : "";
142
+ const site = business(), member = await memberAccess();
143
+ if (!member.email) return signInState();
144
+ if (!member.scope || (input()?.readLifecycleVersion ?? 0) < 1) return { ...INITIAL, hideLoading: true, hideError: false, error: copyParam("loyalty.updateApp", "Update the app to see your points securely.") };
145
+ if (uncertainPrincipal !== stamp(member)) { uncertain.clear(); uncertaintyOverflow = false; confirmed = undefined; uncertainPrincipal = stamp(member); }
146
+ try {
147
+ const [program, rewards, account, coupons, referrals] = await Promise.all([getLoyaltyProgram(), listLoyaltyRewards(), getCurrentMemberLoyaltyAccount(), listMyLoyaltyCoupons(),
148
+ listMyReferralRewards().then(value => ({ value, failed: false }), () => ({ value: undefined, failed: true }))]);
149
+ await assertMemberAccess(member);
150
+ if (business() !== site || version !== generation) return undefined;
151
+ const p = program.loyaltyProgram ?? program.program;
152
+ if (!p || p.status !== "ACTIVE" || p.premiumFeatures?.loyaltyProgram === false) throw new Error("Program unavailable");
153
+ if ((rewards.rewards !== undefined && !Array.isArray(rewards.rewards)) || (coupons.loyaltyCoupons !== undefined && !Array.isArray(coupons.loyaltyCoupons))) throw new Error("Incomplete member rewards response");
154
+ context = { member, business: site, program: p, account: account.account ?? {}, rewards: rewards.rewards ?? [], coupons: coupons.loyaltyCoupons ?? [], referrals: referrals.value?.referralRewards ?? [], referralKnown: !referrals.failed };
155
+ reconcile(context);
156
+ return { ...INITIAL, hideLoading: true, hideContent: false, ...rows(context) };
157
+ } catch {
158
+ if (version !== generation || business() !== site) return undefined;
159
+ const current = await memberAccess();
160
+ if (!current.email || stamp(current) !== stamp(member)) return signInState();
161
+ return { ...INITIAL, hideLoading: true, hideError: false, error: copyParam("loyalty.memberRewardsError", "Your rewards couldn't be loaded. Try again.") };
162
+ }
163
+ },
164
+ reviewReward: async (s, arg) => {
165
+ const c = context, id = object(arg).id;
166
+ if (!c || typeof id !== "string" || writing || reviewing || blocked(id)) return s;
167
+ const reward = c.rewards.find(r => r.id === id && r.active === true);
168
+ if (!reward) return s;
169
+ reviewing = true;
170
+ try {
171
+ await guard(c);
172
+ if (reward.type === "DISCOUNT_AMOUNT" && restaurantScreen) return navigate(s, { screenId: restaurantScreen });
173
+ if (!couponReward(reward)) return { ...s, message: copyParam("loyalty.checkoutReward", "Use this reward at checkout.") };
174
+ const [freshRewards, freshAccount] = await Promise.all([listLoyaltyRewards(), getCurrentMemberLoyaltyAccount()]);
175
+ await guard(c);
176
+ const fresh = freshRewards.rewards?.find(r => r.id === id && r.active === true);
177
+ if (!fresh || !freshAccount.account) throw new Error("Reward unavailable");
178
+ c.rewards = freshRewards.rewards!; c.account = freshAccount.account;
179
+ const cost = memberRewardCost(fresh, c.account.tier?.id), balance = c.account.points?.balance;
180
+ if (cost === undefined || typeof balance !== "number" || !Number.isFinite(balance) || balance < cost) return { ...s, ...rows(c), message: copyParam("loyalty.cannotRedeem", "This reward can't be redeemed with your current points and tier.") };
181
+ confirmation = { id, member: c.member, business: c.business, cost };
182
+ return { ...s, ...rows(c), selectedId: id, confirming: true, message: "", confirmation: copyTemplate("loyalty.confirmReward", "Redeem {name} for {points}?", { name: fresh.name || copyParam("loyalty.rewardFallback", "Reward"), points: pointLabel(cost, c.program.pointDefinition?.customName ?? "") }) };
183
+ } catch {
184
+ confirmation = undefined;
185
+ const current = await memberAccess();
186
+ if (context !== c || business() !== c.business || stamp(current) !== stamp(c.member)) return signInState();
187
+ return { ...s, confirming: false, message: copyParam("loyalty.cannotRedeem", "This reward can't be redeemed with your current points and tier.") }; }
188
+ finally { reviewing = false; }
189
+ },
190
+ cancelRedemption: s => { if (writing) return s; confirmation = undefined; return { ...s, confirming: false, selectedId: "", confirmation: "" }; },
191
+ confirmRedemption: s => {
192
+ const c = context, selected = confirmation;
193
+ if (!c || !selected || !s.confirming || writing || blocked(selected.id) || selected.business !== business() || selected.member !== c.member) return s;
194
+ writing = true; confirmation = undefined;
195
+ void redeem(c, selected, generation);
196
+ return { ...s, writeBusy: true, message: copyParam("loyalty.redeeming", "Redeeming…") };
197
+ },
198
+ finishRedemption: s => {
199
+ if (!result || result.generation !== generation || !context) return s;
200
+ const done = result; result = undefined;
201
+ return { ...s, hideLoading: true, hideContent: false, hideSignIn: true, hideError: true, ...rows(context), confirming: false, selectedId: "", confirmation: "", writeBusy: false, message: done.message,
202
+ resultCode: done.coupon?.couponReference?.code ?? "", resultName: done.coupon?.rewardName ?? "" };
203
+ },
204
+ },
205
+ });
206
+ svc.prepare();
207
+ svc.load();
208
+ export { svc };