@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.
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/wix-payments-contract.d.ts +4 -14
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/wix-payments-wire.d.ts +2 -1
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/iap/src/wix-payments.d.ts +1 -2
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/apple-iap-contract.d.ts +26 -13
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/apple-iap-core.d.ts +0 -2
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/checkout-source.d.ts +3 -10
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/commerce-core.d.ts +0 -2
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/iap-order-backend.d.ts +0 -3
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/iap-verticals/stores.d.ts +2 -2
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/flows/iap-checkout/src/index.d.ts +2 -2
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/loyalty/src/index.d.ts +64 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/members-feed-viewer/src/index.d.ts +10 -6
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/members-v1/src/index.d.ts +36 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap-checkout.mjs +13 -45
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/iap.mjs +2 -4
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/screens.mjs +7 -11
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-loyalty.mjs +39 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-members-feed-viewer.mjs +8 -7
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-members.mjs +29 -0
- package/dist/browser-plane/assets.json +166 -0
- package/dist/browser-plane/host.html +156 -0
- package/dist/browser-plane/package-reader.js +4 -0
- package/dist/browser-plane/preview-deferred/368faca44fb9f7207e4af784e94750b1ee59c1098b8689e0f4a918926f244fb5.bin +0 -0
- package/dist/browser-plane/preview-deferred/37c179b4fefb63ea9637322b84583a2df53aa226c150294eea1675c14b1b8e29.bin +0 -0
- package/dist/browser-plane/preview-deferred/4ecf270136af928612c76fe4ebade91d8156975e5ea4c0cf9225501dd1788e02.bin +0 -0
- package/dist/browser-plane/preview-deferred/521a0e528916d678d7588b2cddf3dadd3bb959a6d62426382f61c77a82017e35.bin +1 -0
- package/dist/browser-plane/preview-deferred/62e2fe4718129f36ecdb0ae4de385bd6b81ecc876146447167c6c140fad699a9.bin +0 -0
- package/dist/browser-plane/preview-deferred/642feab8a48ddf6c7a89d7c2a7c0edfa0e6e773f671287e460e222703563191e.bin +0 -0
- package/dist/browser-plane/preview-deferred/97ed8f1a6772abe26e3d8af5cecfc4b02faa94497d585b0398b5053b739a70c1.bin +0 -0
- package/dist/browser-plane/preview-deferred/a33cfe728c584fdba4fcc1fd54bcdc2f9f2f13889ddbb5b2bd1d0f8cbe49b84e.bin +28 -0
- package/dist/browser-plane/preview-deferred/bf4c16a2774edd38a921edf3099469866cd74c094fd596f3fa4e81f54a04dfaa.bin +0 -0
- package/dist/browser-plane/preview-deferred/c3457ce58c96b65f303fec0ddf5f06733004eaf9e21e62302b155ec63a7765f7.bin +56 -0
- package/dist/browser-plane/preview-deferred/d7598e12c5dbef095ff8272cfc55da0250bd07fbdecbac8a530b9b277872a134.bin +0 -0
- package/dist/browser-plane/preview-deferred/dced97a77624bc5f8b1ae65850360908297f4588e09feb6f22792d29dc7e765b.bin +0 -0
- package/dist/browser-plane/preview-deferred/dee56f09fd3efc127c1ea95557edc74110c1460a46287da36e0af25efd341ef5.bin +0 -0
- package/dist/browser-plane/preview-deferred/ec4c7d63772d980d6e147b2df6215fd2737e4b04cebe0cf7e11dc695fb63cdcf.bin +0 -0
- package/dist/browser-plane/sw.js +13 -0
- package/dist/browser-plane/t5-frame.html +350 -0
- package/dist/browser-plane/t5-worker-frame.html +350 -0
- package/dist/cli.mjs +6499 -5089
- package/dist/preview-assets.generated.bin +0 -0
- package/fixtures/wix-canned.json +34 -1
- package/guides/getting-started.md +2 -1
- package/guides/himi-json-output.md +8 -0
- package/guides/himi-skill.md +2 -1
- package/guides/iap-screen-adoption.md +4 -4
- package/guides/importable-sdks.md +1 -1
- package/guides/one-app-section-mappings.md +3 -3
- package/guides/one-app-to-himi-app.md +81 -5
- package/guides/sdk-reference.json +8862 -4388
- package/guides/skill.md +11 -1
- package/package.json +3 -3
- package/templates/beauty-salon/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/beauty-salon/tier5-src/himi-store-product/index.ts +1 -1
- package/templates/branded-lite/config.ts +7 -0
- package/templates/branded-lite/dev/index.ts +3340 -1890
- package/templates/branded-lite/himalaya.content.json +3 -0
- package/templates/branded-lite/l10n/en.json +8 -4
- package/templates/branded-lite/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/branded-lite/tier5-src/_shared/business-chat-history.ts +4 -1
- package/templates/branded-lite/tier5-src/_shared/business-chat-presenter.ts +2 -7
- package/templates/branded-lite/tier5-src/_shared/header-badges.ts +1 -1
- package/templates/branded-lite/tier5-src/_shared/inbox-rooms.ts +68 -0
- package/templates/branded-lite/tier5-src/_shared/loyalty-offerings.ts +73 -0
- package/templates/branded-lite/tier5-src/_shared/member-activity.ts +34 -0
- package/templates/branded-lite/tier5-src/_shared/member-row-extras.ts +167 -0
- package/templates/branded-lite/tier5-src/_shared/notification-target.ts +141 -0
- package/templates/branded-lite/tier5-src/_shared/source-badges.ts +6 -4
- package/templates/branded-lite/tier5-src/_shared/wix-member-links.ts +114 -0
- package/templates/branded-lite/tier5-src/business-chat/index.ts +114 -59
- package/templates/branded-lite/tier5-src/himi-branded-lite-inbox/index.ts +83 -0
- package/templates/branded-lite/tier5-src/himi-branded-lite-loyalty/earning.ts +148 -0
- package/templates/branded-lite/tier5-src/himi-branded-lite-loyalty/index.ts +57 -31
- package/templates/branded-lite/tier5-src/himi-branded-lite-loyalty-rewards/index.ts +208 -0
- package/templates/branded-lite/tier5-src/himi-branded-lite-notifications/index.ts +62 -7
- package/templates/branded-lite/tier5-src/himi-store-product/index.ts +1 -1
- package/templates/branded-lite/tier5-src/wix-spaces-members/index.ts +60 -12
- package/templates/community-club/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/events-meetups/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/gym-fitness/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/gym-fitness/tier5-src/himi-store-product/index.ts +1 -1
- package/templates/nonprofit-cause/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/podcast-creator/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/portfolio-creative/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/portfolio-creative/tier5-src/himi-store-product/index.ts +1 -1
- package/templates/restaurant/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/retail-store/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/retail-store/tier5-src/himi-store-product/index.ts +1 -1
- package/templates/salon-spa/dev/index.ts +112 -70
- package/templates/salon-spa/tier5-src/_shared/branded-lite-hosted.ts +1 -0
- package/templates/salon-spa/tier5-src/_shared/business-chat-history.ts +4 -1
- package/templates/salon-spa/tier5-src/_shared/business-chat-presenter.ts +2 -7
- package/templates/salon-spa/tier5-src/_shared/inbox-rooms.ts +68 -0
- package/templates/salon-spa/tier5-src/business-chat/index.ts +114 -59
- 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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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 };
|