@wix/himalaya-cli 0.1476.0 → 0.1478.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/cli.mjs +751 -540
- package/dist/preview-assets.generated.bin +0 -0
- package/guides/component-reference.json +6 -53
- package/guides/component-reference.md +4 -16
- package/package.json +2 -2
- package/templates/beauty-salon/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/beauty-salon/tier5-src/_shared/himi-bookings.ts +35 -0
- package/templates/beauty-salon/tier5-src/_shared/host-events.ts +45 -6
- package/templates/beauty-salon/tier5-src/himi-booking-bookings/index.ts +4 -0
- package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +34 -13
- package/templates/beauty-salon-owner/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/branded-lite/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/branded-lite/tier5-src/_shared/himi-bookings.ts +35 -0
- package/templates/branded-lite/tier5-src/_shared/host-events.ts +45 -6
- package/templates/branded-lite/tier5-src/himi-booking-bookings/index.ts +4 -0
- package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +34 -13
- package/templates/community-club/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/community-club-owner/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/events-meetups/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/events-meetups-owner/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/gym-fitness/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/gym-fitness/tier5-src/_shared/himi-bookings.ts +35 -0
- package/templates/gym-fitness/tier5-src/_shared/host-events.ts +45 -6
- package/templates/gym-fitness/tier5-src/himi-booking-bookings/index.ts +4 -0
- package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +34 -13
- package/templates/gym-fitness-owner/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/nonprofit-cause/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/nonprofit-cause-owner/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/owner-lite/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/podcast-creator/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/podcast-creator-owner/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/portfolio-creative/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/portfolio-creative-owner/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/restaurant/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/restaurant-owner/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/retail-store/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/retail-store-owner/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/yoga-studio/tier5-src/_shared/engine-intl.ts +21 -0
- package/templates/yoga-studio/tier5-src/_shared/himi-bookings.ts +35 -0
- package/templates/yoga-studio/tier5-src/_shared/host-events.ts +45 -6
- package/templates/yoga-studio/tier5-src/himi-booking-bookings/index.ts +4 -0
- package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +34 -13
- package/templates/yoga-studio-owner/tier5-src/_shared/engine-intl.ts +21 -0
|
@@ -4,6 +4,7 @@ import { navigate } from "@himalaya/state";
|
|
|
4
4
|
import { copyParam } from "../_shared/himi-copy.js";
|
|
5
5
|
import { memberAccess, assertMemberAccess, memberAccessChanged, memberAccessDenied, type MemberAccess } from "../_shared/member-access.js";
|
|
6
6
|
import { fetchMemberBookings, type MemberBookingRow } from "../_shared/member-bookings.js";
|
|
7
|
+
import { emitHostEvent } from "../_shared/host-events.js";
|
|
7
8
|
import { dayKey, dayLabel } from "@himalaya/time";
|
|
8
9
|
import { localPreferences } from "../_shared/local-preferences.js";
|
|
9
10
|
import { parseWeeklyGoal, weeklyGoalProgress, weeklySessionsFromPages } from "../_shared/weekly-goal.js";
|
|
@@ -136,6 +137,9 @@ const svc = defineBackendCollection<S, MemberBookingRow>("himiBookingBookings",
|
|
|
136
137
|
await cancelBookingAsMember(target.row.id, target.row.revision);
|
|
137
138
|
await assertMemberAccess(target.member);
|
|
138
139
|
cancelTarget = undefined;
|
|
140
|
+
// Told to the app hosting this screen (an embed's `onAction`); dropped where nothing hosts it.
|
|
141
|
+
await emitHostEvent("booking.cancelled", { bookingId: target.row.id, serviceId: target.row.serviceId,
|
|
142
|
+
serviceName: target.row.title, start: target.row.start, end: target.row.end });
|
|
139
143
|
await svc.refresh();
|
|
140
144
|
return derive({ ...(svc.getState() as unknown as S), actionMessage: copyParam("bookings.cancelDone", "Booking cancelled.") });
|
|
141
145
|
} catch (error) {
|
|
@@ -16,7 +16,8 @@ import { formatServicePrice, serviceIsChargeable } from "../_shared/wix-service-
|
|
|
16
16
|
import { stashCopy, copyParam } from "../_shared/himi-copy.js";
|
|
17
17
|
import { upsert as dbUpsert, remove as dbRemove, enqueue as dbEnqueue, type DbEntity } from "@himalaya/local-db";
|
|
18
18
|
import { isBridgeUnavailable } from "../_shared/owner-load-error.js";
|
|
19
|
-
import { emitHostEvent } from "../_shared/host-events.js";
|
|
19
|
+
import { emitHostEvent, hostEventInstant } from "../_shared/host-events.js";
|
|
20
|
+
import { slotIdAt } from "../_shared/himi-bookings.js";
|
|
20
21
|
import { wixMediaUrl, WIX_DETAIL_IMAGE } from "@himalaya/wix-media";
|
|
21
22
|
|
|
22
23
|
// Offline-first (data-offline): booking a slot is WRITE-BEHIND against the
|
|
@@ -147,12 +148,23 @@ function inputId(): string {
|
|
|
147
148
|
// `id` nav param (himi.open { screenId: "himi_time", id }) into worker input.
|
|
148
149
|
// The screenId-slice below is #570 back-compat for pre-params releases with
|
|
149
150
|
// baked `himi_time_<id>` screenIds — do not author against this.
|
|
150
|
-
|
|
151
|
+
// `serviceId` is the same thing under the name a HOST uses to open this screen (the deep-entry
|
|
152
|
+
// contract: Branded Lite README, "Opening at a booking").
|
|
153
|
+
const input = (globalThis as { himalaya?: { input?: { id?: unknown; serviceId?: unknown; screenId?: unknown } } }).himalaya?.input;
|
|
151
154
|
if (typeof input?.id === "string" && input.id) return input.id;
|
|
155
|
+
if (typeof input?.serviceId === "string" && input.serviceId) return input.serviceId;
|
|
152
156
|
const screenId = typeof input?.screenId === "string" ? input.screenId : "";
|
|
153
157
|
return screenId.startsWith("himi_time_") ? screenId.slice("himi_time_".length) : "";
|
|
154
158
|
}
|
|
155
159
|
|
|
160
|
+
/** The time a host opened this screen at (`himalaya.input.start`, ISO 8601), or "". Every load
|
|
161
|
+
* preselects that slot (or opens on its day) until the member picks a day or a time themselves —
|
|
162
|
+
* not just the first, because the boot load and the screen's own `load` can overlap. */
|
|
163
|
+
let pendingStart = (() => {
|
|
164
|
+
const start = (globalThis as { himalaya?: { input?: { start?: unknown } } }).himalaya?.input?.start;
|
|
165
|
+
return typeof start === "string" && Number.isFinite(Date.parse(start)) ? start : "";
|
|
166
|
+
})();
|
|
167
|
+
|
|
156
168
|
function strOf(arg: unknown): string {
|
|
157
169
|
if (typeof arg === "string") return arg;
|
|
158
170
|
const a = arg as { value?: string; text?: string } | undefined;
|
|
@@ -534,15 +546,20 @@ const svc = defineService<S, {
|
|
|
534
546
|
const days = daysOf(rows);
|
|
535
547
|
// Keep the customer's day across a reload when it still has times;
|
|
536
548
|
// otherwise open on the first day that has any.
|
|
549
|
+
// A host's `start` (deep entry): that slot preselected, or — taken since — its day.
|
|
550
|
+
const wantedStart = pendingStart;
|
|
551
|
+
const startRow = wantedStart ? rows.find((r) => r.id === slotIdAt(slotsById, wantedStart)) : undefined;
|
|
552
|
+
// The requested day even with no time left on it: "taken since" opens there, nothing selected.
|
|
553
|
+
const startDay = wantedStart ? days.find((d) => d.id === dayKey(wantedStart)) : undefined;
|
|
537
554
|
const keep = days.find((d) => d.id === next.selectedDay && d.hasSlots);
|
|
538
|
-
const initialDay = keep?.id ?? days.find((d) => d.hasSlots)?.id ?? days[0]?.id ?? "";
|
|
539
|
-
const selectionAlive = slotsById.has(next.selectedSlotId);
|
|
555
|
+
const initialDay = startRow?.dayKey ?? startDay?.id ?? keep?.id ?? days.find((d) => d.hasSlots)?.id ?? days[0]?.id ?? "";
|
|
556
|
+
const selectionAlive = !!startRow || slotsById.has(next.selectedSlotId);
|
|
540
557
|
const header = serviceHeaderFields(serviceRes.service);
|
|
541
558
|
return derive(withDay({
|
|
542
559
|
...next,
|
|
543
560
|
days,
|
|
544
|
-
selectedSlotId: selectionAlive ? next.selectedSlotId : "",
|
|
545
|
-
selectedLine: selectionAlive ? next.selectedLine : "",
|
|
561
|
+
selectedSlotId: startRow ? startRow.id : selectionAlive ? next.selectedSlotId : "",
|
|
562
|
+
selectedLine: startRow ? [startRow.day, startRow.title, startRow.subtitle].filter(Boolean).join(" · ") : selectionAlive ? next.selectedLine : "",
|
|
546
563
|
status: "ready",
|
|
547
564
|
errored: false,
|
|
548
565
|
serviceName: serviceRes.service?.name || copyParam("time.serviceNameFallback", "Choose a time"),
|
|
@@ -567,6 +584,7 @@ const svc = defineService<S, {
|
|
|
567
584
|
selectDay: (s, arg) => {
|
|
568
585
|
const day = chosenDay(arg);
|
|
569
586
|
if (!day || !s.days.some((d) => d.id === day)) return derive(s);
|
|
587
|
+
pendingStart = "";
|
|
570
588
|
return derive(withDay(s, day));
|
|
571
589
|
},
|
|
572
590
|
refreshMember: (s, arg) => { stashCopy(arg); return probeMember(s); },
|
|
@@ -575,6 +593,7 @@ const svc = defineService<S, {
|
|
|
575
593
|
? arg
|
|
576
594
|
: ((arg as { id?: string; slotId?: string })?.slotId ?? (arg as { id?: string })?.id ?? "");
|
|
577
595
|
const selected = allRows.find((slot) => slot.id === slotId);
|
|
596
|
+
if (selected) pendingStart = "";
|
|
578
597
|
return derive({
|
|
579
598
|
...s,
|
|
580
599
|
selectedSlotId: selected ? slotId : s.selectedSlotId,
|
|
@@ -638,12 +657,14 @@ const svc = defineService<S, {
|
|
|
638
657
|
if (!slot) return derive({ ...s, message: copyParam("time.bookSlotGone", "That time is no longer available. Try again.") });
|
|
639
658
|
// A confirmed booking is reported to the app hosting this screen (an embed's `onAction`), so a
|
|
640
659
|
// host chat can say so and refresh what it shows. Dropped where nothing hosts the screen.
|
|
641
|
-
|
|
642
|
-
|
|
660
|
+
// The booking's own instants carry an offset; a slot's may be LOCAL to its timezone, which
|
|
661
|
+
// `emitHostEvent` leaves out rather than let the host read it in its own zone.
|
|
662
|
+
const reportBooked = (booking: { id?: string; startDate?: string; endDate?: string } | undefined) => emitHostEvent("booking.created", {
|
|
663
|
+
bookingId: booking?.id,
|
|
643
664
|
serviceId: s.id,
|
|
644
665
|
serviceName: s.serviceName,
|
|
645
|
-
start: slot.startDate,
|
|
646
|
-
end: slot.endDate,
|
|
666
|
+
start: hostEventInstant(booking?.startDate) || slot.startDate,
|
|
667
|
+
end: hostEventInstant(booking?.endDate) || slot.endDate,
|
|
647
668
|
});
|
|
648
669
|
// PAID service — FULLY NATIVE in-app payment (mirrors Wix's RN apps): NO
|
|
649
670
|
// hosted-checkout WebView / browser redirect. The chain is
|
|
@@ -679,7 +700,7 @@ const svc = defineService<S, {
|
|
|
679
700
|
const orderRes = hosted ? { paymentGatewayOrderId: "" } : await createOrder(checkoutId);
|
|
680
701
|
const snapshotId = orderRes.paymentGatewayOrderId ?? "";
|
|
681
702
|
if (!snapshotId && !hosted) {
|
|
682
|
-
await reportBooked(
|
|
703
|
+
await reportBooked(created.booking);
|
|
683
704
|
return derive({ ...pending, bookingInFlight: false, successMessage: copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."), message: "" });
|
|
684
705
|
}
|
|
685
706
|
// 4. Collect payment IN-APP — wallet when this app has one wired, otherwise
|
|
@@ -701,7 +722,7 @@ const svc = defineService<S, {
|
|
|
701
722
|
},
|
|
702
723
|
});
|
|
703
724
|
if (pay.kind === "paid") {
|
|
704
|
-
await reportBooked(
|
|
725
|
+
await reportBooked(created.booking);
|
|
705
726
|
return derive({ ...pending, bookingInFlight: false, successMessage: copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."), message: "" });
|
|
706
727
|
}
|
|
707
728
|
if (pay.kind === "handoff") {
|
|
@@ -740,7 +761,7 @@ const svc = defineService<S, {
|
|
|
740
761
|
// Reconcile the optimistic local id with the server-assigned booking id.
|
|
741
762
|
const realId = created.booking?.id;
|
|
742
763
|
if (realId && realId !== localId) { await cacheDrop(localId); await cachePut(localRow(realId)); }
|
|
743
|
-
await reportBooked(
|
|
764
|
+
await reportBooked(created.booking);
|
|
744
765
|
return derive({
|
|
745
766
|
...pending,
|
|
746
767
|
bookingInFlight: false,
|
|
@@ -444,3 +444,24 @@ export function calendarDay(date: Date, timeZone?: string): string {
|
|
|
444
444
|
}
|
|
445
445
|
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
446
446
|
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* [timeZone]'s offset from UTC in ms at instant [at] (Asia/Jerusalem in winter: 7_200_000), or NaN
|
|
450
|
+
* where it cannot be known — no ECMA-402 (the wrist engines) or a zone this engine does not know.
|
|
451
|
+
* Callers fall back to the device zone on NaN, as the formatters above do.
|
|
452
|
+
*/
|
|
453
|
+
export function zoneOffsetAt(at: number, timeZone: string): number {
|
|
454
|
+
if (hasIntl() && Number.isFinite(at)) {
|
|
455
|
+
try {
|
|
456
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
457
|
+
timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit",
|
|
458
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
459
|
+
}).formatToParts(new Date(at));
|
|
460
|
+
const n = (type: string) => Number(parts.find((p) => p.type === type)?.value);
|
|
461
|
+
return Date.UTC(n("year"), n("month") - 1, n("day"), n("hour") % 24, n("minute"), n("second")) - at;
|
|
462
|
+
} catch {
|
|
463
|
+
/* a zone this engine does not know: NaN below */
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return NaN;
|
|
467
|
+
}
|
|
@@ -444,3 +444,24 @@ export function calendarDay(date: Date, timeZone?: string): string {
|
|
|
444
444
|
}
|
|
445
445
|
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
446
446
|
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* [timeZone]'s offset from UTC in ms at instant [at] (Asia/Jerusalem in winter: 7_200_000), or NaN
|
|
450
|
+
* where it cannot be known — no ECMA-402 (the wrist engines) or a zone this engine does not know.
|
|
451
|
+
* Callers fall back to the device zone on NaN, as the formatters above do.
|
|
452
|
+
*/
|
|
453
|
+
export function zoneOffsetAt(at: number, timeZone: string): number {
|
|
454
|
+
if (hasIntl() && Number.isFinite(at)) {
|
|
455
|
+
try {
|
|
456
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
457
|
+
timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit",
|
|
458
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
459
|
+
}).formatToParts(new Date(at));
|
|
460
|
+
const n = (type: string) => Number(parts.find((p) => p.type === type)?.value);
|
|
461
|
+
return Date.UTC(n("year"), n("month") - 1, n("day"), n("hour") % 24, n("minute"), n("second")) - at;
|
|
462
|
+
} catch {
|
|
463
|
+
/* a zone this engine does not know: NaN below */
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return NaN;
|
|
467
|
+
}
|
|
@@ -444,3 +444,24 @@ export function calendarDay(date: Date, timeZone?: string): string {
|
|
|
444
444
|
}
|
|
445
445
|
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
446
446
|
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* [timeZone]'s offset from UTC in ms at instant [at] (Asia/Jerusalem in winter: 7_200_000), or NaN
|
|
450
|
+
* where it cannot be known — no ECMA-402 (the wrist engines) or a zone this engine does not know.
|
|
451
|
+
* Callers fall back to the device zone on NaN, as the formatters above do.
|
|
452
|
+
*/
|
|
453
|
+
export function zoneOffsetAt(at: number, timeZone: string): number {
|
|
454
|
+
if (hasIntl() && Number.isFinite(at)) {
|
|
455
|
+
try {
|
|
456
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
457
|
+
timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit",
|
|
458
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
459
|
+
}).formatToParts(new Date(at));
|
|
460
|
+
const n = (type: string) => Number(parts.find((p) => p.type === type)?.value);
|
|
461
|
+
return Date.UTC(n("year"), n("month") - 1, n("day"), n("hour") % 24, n("minute"), n("second")) - at;
|
|
462
|
+
} catch {
|
|
463
|
+
/* a zone this engine does not know: NaN below */
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return NaN;
|
|
467
|
+
}
|
|
@@ -444,3 +444,24 @@ export function calendarDay(date: Date, timeZone?: string): string {
|
|
|
444
444
|
}
|
|
445
445
|
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
446
446
|
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* [timeZone]'s offset from UTC in ms at instant [at] (Asia/Jerusalem in winter: 7_200_000), or NaN
|
|
450
|
+
* where it cannot be known — no ECMA-402 (the wrist engines) or a zone this engine does not know.
|
|
451
|
+
* Callers fall back to the device zone on NaN, as the formatters above do.
|
|
452
|
+
*/
|
|
453
|
+
export function zoneOffsetAt(at: number, timeZone: string): number {
|
|
454
|
+
if (hasIntl() && Number.isFinite(at)) {
|
|
455
|
+
try {
|
|
456
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
457
|
+
timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit",
|
|
458
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
459
|
+
}).formatToParts(new Date(at));
|
|
460
|
+
const n = (type: string) => Number(parts.find((p) => p.type === type)?.value);
|
|
461
|
+
return Date.UTC(n("year"), n("month") - 1, n("day"), n("hour") % 24, n("minute"), n("second")) - at;
|
|
462
|
+
} catch {
|
|
463
|
+
/* a zone this engine does not know: NaN below */
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return NaN;
|
|
467
|
+
}
|
|
@@ -444,3 +444,24 @@ export function calendarDay(date: Date, timeZone?: string): string {
|
|
|
444
444
|
}
|
|
445
445
|
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
446
446
|
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* [timeZone]'s offset from UTC in ms at instant [at] (Asia/Jerusalem in winter: 7_200_000), or NaN
|
|
450
|
+
* where it cannot be known — no ECMA-402 (the wrist engines) or a zone this engine does not know.
|
|
451
|
+
* Callers fall back to the device zone on NaN, as the formatters above do.
|
|
452
|
+
*/
|
|
453
|
+
export function zoneOffsetAt(at: number, timeZone: string): number {
|
|
454
|
+
if (hasIntl() && Number.isFinite(at)) {
|
|
455
|
+
try {
|
|
456
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
457
|
+
timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit",
|
|
458
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
459
|
+
}).formatToParts(new Date(at));
|
|
460
|
+
const n = (type: string) => Number(parts.find((p) => p.type === type)?.value);
|
|
461
|
+
return Date.UTC(n("year"), n("month") - 1, n("day"), n("hour") % 24, n("minute"), n("second")) - at;
|
|
462
|
+
} catch {
|
|
463
|
+
/* a zone this engine does not know: NaN below */
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return NaN;
|
|
467
|
+
}
|
|
@@ -16,3 +16,38 @@ export function categoryName(value: unknown): string {
|
|
|
16
16
|
const name = (value as { name?: unknown }).name;
|
|
17
17
|
return typeof name === "string" ? name.trim() : "";
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
import { zoneOffsetAt } from "./engine-intl.js";
|
|
21
|
+
|
|
22
|
+
const HAS_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/i;
|
|
23
|
+
const LOCAL_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/;
|
|
24
|
+
|
|
25
|
+
/** The instant (ms) a slot starts. Wix may list a slot's `startDate` as a LOCAL wall time with its
|
|
26
|
+
* `timezone` beside it ("2026-11-02T18:30:00" in America/New_York is 23:30Z); a bare `Date.parse`
|
|
27
|
+
* would read that in the device's zone. So a local time is placed in the slot's own zone when this
|
|
28
|
+
* engine has Intl and knows the zone, and read in the device's zone only when it cannot be. */
|
|
29
|
+
export function slotStartInstant(slot: { startDate?: string; timezone?: unknown }): number {
|
|
30
|
+
const start = slot.startDate ?? "";
|
|
31
|
+
if (!start) return NaN;
|
|
32
|
+
const local = LOCAL_TIME.exec(start);
|
|
33
|
+
if (HAS_OFFSET.test(start) || !local || typeof slot.timezone !== "string" || !slot.timezone) return Date.parse(start);
|
|
34
|
+
const wall = Date.UTC(+local[1], +local[2] - 1, +local[3], +local[4], +local[5], +(local[6] ?? 0));
|
|
35
|
+
const offset = zoneOffsetAt(wall, slot.timezone);
|
|
36
|
+
if (!Number.isFinite(offset)) return Date.parse(start);
|
|
37
|
+
// Once more at the corrected instant, so a wall time beside a DST change lands on the right side.
|
|
38
|
+
const again = zoneOffsetAt(wall - offset, slot.timezone);
|
|
39
|
+
return wall - (Number.isFinite(again) ? again : offset);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The slot a host asked to open at (`himi_time` input `start`, an ISO 8601 instant): the id of the
|
|
43
|
+
* slot that starts at that same instant, or "" when none does (taken since, or out of the window).
|
|
44
|
+
* Compared as instants, so `…T16:30:00Z` finds a slot listed as `…T18:30:00+02:00`, or as a local
|
|
45
|
+
* `…T18:30:00` beside `timezone: "Asia/Jerusalem"`. */
|
|
46
|
+
export function slotIdAt(slots: Iterable<[string, { startDate?: string; timezone?: unknown }]>, start: string): string {
|
|
47
|
+
const at = start ? Date.parse(start) : NaN;
|
|
48
|
+
if (!Number.isFinite(at)) return "";
|
|
49
|
+
for (const [id, slot] of slots) {
|
|
50
|
+
if (slotStartInstant(slot) === at) return id;
|
|
51
|
+
}
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
@@ -1,26 +1,65 @@
|
|
|
1
1
|
// Host events — what a member screen tells the app hosting it about something the member did
|
|
2
|
-
// (a booking confirmed), so a host such as Symphony can say so in its chat and refresh
|
|
2
|
+
// (a booking confirmed or cancelled), so a host such as Symphony can say so in its chat and refresh
|
|
3
|
+
// what it shows.
|
|
3
4
|
//
|
|
4
5
|
// A worker dispatches `host.event` with `{ type, ...fields }` (string values only: a dispatch carries
|
|
5
6
|
// one string params map). An embed (`HimalayaEmbed`) hands it to the host's `onAction` as an action
|
|
6
7
|
// with that id; anywhere else nothing handles it and it is dropped. It is a notice, never a request:
|
|
7
8
|
// the host owns what happens next, and the worker never waits on it or depends on its answer.
|
|
9
|
+
//
|
|
10
|
+
// The contract (hosts parse exactly this — Symphony's `PlaceHostEvent`):
|
|
11
|
+
// type booking.created | booking.cancelled | plan.used
|
|
12
|
+
// serviceName the class / service title (bookings) planName the plan's name (plan.used)
|
|
13
|
+
// start, end ISO 8601 instants WITH an offset (`Z` or `+02:00`); a local time is left out
|
|
14
|
+
// remaining sessions left on the plan, when known (a whole number)
|
|
15
|
+
// bookingId, serviceId, planId when known
|
|
8
16
|
|
|
9
17
|
import { dispatch } from "@himalaya/dispatch";
|
|
10
18
|
|
|
11
19
|
export const HOST_EVENT_ACTION_ID = "host.event";
|
|
12
20
|
|
|
13
21
|
/** The events a member screen reports. */
|
|
14
|
-
export type HostEventType = "booking.created";
|
|
22
|
+
export type HostEventType = "booking.created" | "booking.cancelled" | "plan.used";
|
|
15
23
|
|
|
16
|
-
|
|
17
|
-
|
|
24
|
+
export interface HostEventFields {
|
|
25
|
+
bookingId?: string;
|
|
26
|
+
serviceId?: string;
|
|
27
|
+
serviceName?: string;
|
|
28
|
+
planId?: string;
|
|
29
|
+
planName?: string;
|
|
30
|
+
start?: string;
|
|
31
|
+
end?: string;
|
|
32
|
+
remaining?: number | string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const WITH_OFFSET = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$/i;
|
|
36
|
+
|
|
37
|
+
/** [value] when it is an ISO 8601 instant that names its offset, else "". A slot's LOCAL wall time
|
|
38
|
+
* ("2026-11-02T18:30:00") would be read in the host's zone, i.e. a wrong hour, so it is left out. */
|
|
39
|
+
export function hostEventInstant(value: unknown): string {
|
|
40
|
+
return typeof value === "string" && WITH_OFFSET.test(value) && Number.isFinite(Date.parse(value)) ? value : "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The `host.event` params for [type] and [fields]: unset and empty fields are left out, times
|
|
44
|
+
* without an offset are left out, and `remaining` is a whole number ≥ 0 or nothing. */
|
|
45
|
+
export function hostEventParams(type: HostEventType, fields: HostEventFields = {}): Record<string, string> {
|
|
18
46
|
const params: Record<string, string> = { type };
|
|
19
47
|
for (const [key, value] of Object.entries(fields)) {
|
|
20
|
-
|
|
48
|
+
let out = "";
|
|
49
|
+
if (key === "start" || key === "end") out = hostEventInstant(value);
|
|
50
|
+
else if (key === "remaining") {
|
|
51
|
+
const n = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
|
|
52
|
+
out = Number.isInteger(n) && n >= 0 ? String(n) : "";
|
|
53
|
+
} else if (typeof value === "string") out = value.trim();
|
|
54
|
+
if (out) params[key] = out;
|
|
21
55
|
}
|
|
56
|
+
return params;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Tell the host about [type]. Never throws: a host that does not listen must not break the screen. */
|
|
60
|
+
export async function emitHostEvent(type: HostEventType, fields: HostEventFields = {}): Promise<void> {
|
|
22
61
|
try {
|
|
23
|
-
await dispatch(HOST_EVENT_ACTION_ID,
|
|
62
|
+
await dispatch(HOST_EVENT_ACTION_ID, hostEventParams(type, fields));
|
|
24
63
|
} catch {
|
|
25
64
|
// Not embedded, or the host has no handler: nothing to tell.
|
|
26
65
|
}
|
|
@@ -4,6 +4,7 @@ import { navigate } from "@himalaya/state";
|
|
|
4
4
|
import { copyParam } from "../_shared/himi-copy.js";
|
|
5
5
|
import { memberAccess, assertMemberAccess, memberAccessChanged, memberAccessDenied, type MemberAccess } from "../_shared/member-access.js";
|
|
6
6
|
import { fetchMemberBookings, type MemberBookingRow } from "../_shared/member-bookings.js";
|
|
7
|
+
import { emitHostEvent } from "../_shared/host-events.js";
|
|
7
8
|
import { dayKey, dayLabel } from "@himalaya/time";
|
|
8
9
|
import { localPreferences } from "../_shared/local-preferences.js";
|
|
9
10
|
import { parseWeeklyGoal, weeklyGoalProgress, weeklySessionsFromPages } from "../_shared/weekly-goal.js";
|
|
@@ -136,6 +137,9 @@ const svc = defineBackendCollection<S, MemberBookingRow>("himiBookingBookings",
|
|
|
136
137
|
await cancelBookingAsMember(target.row.id, target.row.revision);
|
|
137
138
|
await assertMemberAccess(target.member);
|
|
138
139
|
cancelTarget = undefined;
|
|
140
|
+
// Told to the app hosting this screen (an embed's `onAction`); dropped where nothing hosts it.
|
|
141
|
+
await emitHostEvent("booking.cancelled", { bookingId: target.row.id, serviceId: target.row.serviceId,
|
|
142
|
+
serviceName: target.row.title, start: target.row.start, end: target.row.end });
|
|
139
143
|
await svc.refresh();
|
|
140
144
|
return derive({ ...(svc.getState() as unknown as S), actionMessage: copyParam("bookings.cancelDone", "Booking cancelled.") });
|
|
141
145
|
} catch (error) {
|
|
@@ -16,7 +16,8 @@ import { formatServicePrice, serviceIsChargeable } from "../_shared/wix-service-
|
|
|
16
16
|
import { stashCopy, copyParam } from "../_shared/himi-copy.js";
|
|
17
17
|
import { upsert as dbUpsert, remove as dbRemove, enqueue as dbEnqueue, type DbEntity } from "@himalaya/local-db";
|
|
18
18
|
import { isBridgeUnavailable } from "../_shared/owner-load-error.js";
|
|
19
|
-
import { emitHostEvent } from "../_shared/host-events.js";
|
|
19
|
+
import { emitHostEvent, hostEventInstant } from "../_shared/host-events.js";
|
|
20
|
+
import { slotIdAt } from "../_shared/himi-bookings.js";
|
|
20
21
|
import { wixMediaUrl, WIX_DETAIL_IMAGE } from "@himalaya/wix-media";
|
|
21
22
|
|
|
22
23
|
// Offline-first (data-offline): booking a slot is WRITE-BEHIND against the
|
|
@@ -147,12 +148,23 @@ function inputId(): string {
|
|
|
147
148
|
// `id` nav param (himi.open { screenId: "himi_time", id }) into worker input.
|
|
148
149
|
// The screenId-slice below is #570 back-compat for pre-params releases with
|
|
149
150
|
// baked `himi_time_<id>` screenIds — do not author against this.
|
|
150
|
-
|
|
151
|
+
// `serviceId` is the same thing under the name a HOST uses to open this screen (the deep-entry
|
|
152
|
+
// contract: Branded Lite README, "Opening at a booking").
|
|
153
|
+
const input = (globalThis as { himalaya?: { input?: { id?: unknown; serviceId?: unknown; screenId?: unknown } } }).himalaya?.input;
|
|
151
154
|
if (typeof input?.id === "string" && input.id) return input.id;
|
|
155
|
+
if (typeof input?.serviceId === "string" && input.serviceId) return input.serviceId;
|
|
152
156
|
const screenId = typeof input?.screenId === "string" ? input.screenId : "";
|
|
153
157
|
return screenId.startsWith("himi_time_") ? screenId.slice("himi_time_".length) : "";
|
|
154
158
|
}
|
|
155
159
|
|
|
160
|
+
/** The time a host opened this screen at (`himalaya.input.start`, ISO 8601), or "". Every load
|
|
161
|
+
* preselects that slot (or opens on its day) until the member picks a day or a time themselves —
|
|
162
|
+
* not just the first, because the boot load and the screen's own `load` can overlap. */
|
|
163
|
+
let pendingStart = (() => {
|
|
164
|
+
const start = (globalThis as { himalaya?: { input?: { start?: unknown } } }).himalaya?.input?.start;
|
|
165
|
+
return typeof start === "string" && Number.isFinite(Date.parse(start)) ? start : "";
|
|
166
|
+
})();
|
|
167
|
+
|
|
156
168
|
function strOf(arg: unknown): string {
|
|
157
169
|
if (typeof arg === "string") return arg;
|
|
158
170
|
const a = arg as { value?: string; text?: string } | undefined;
|
|
@@ -534,15 +546,20 @@ const svc = defineService<S, {
|
|
|
534
546
|
const days = daysOf(rows);
|
|
535
547
|
// Keep the customer's day across a reload when it still has times;
|
|
536
548
|
// otherwise open on the first day that has any.
|
|
549
|
+
// A host's `start` (deep entry): that slot preselected, or — taken since — its day.
|
|
550
|
+
const wantedStart = pendingStart;
|
|
551
|
+
const startRow = wantedStart ? rows.find((r) => r.id === slotIdAt(slotsById, wantedStart)) : undefined;
|
|
552
|
+
// The requested day even with no time left on it: "taken since" opens there, nothing selected.
|
|
553
|
+
const startDay = wantedStart ? days.find((d) => d.id === dayKey(wantedStart)) : undefined;
|
|
537
554
|
const keep = days.find((d) => d.id === next.selectedDay && d.hasSlots);
|
|
538
|
-
const initialDay = keep?.id ?? days.find((d) => d.hasSlots)?.id ?? days[0]?.id ?? "";
|
|
539
|
-
const selectionAlive = slotsById.has(next.selectedSlotId);
|
|
555
|
+
const initialDay = startRow?.dayKey ?? startDay?.id ?? keep?.id ?? days.find((d) => d.hasSlots)?.id ?? days[0]?.id ?? "";
|
|
556
|
+
const selectionAlive = !!startRow || slotsById.has(next.selectedSlotId);
|
|
540
557
|
const header = serviceHeaderFields(serviceRes.service);
|
|
541
558
|
return derive(withDay({
|
|
542
559
|
...next,
|
|
543
560
|
days,
|
|
544
|
-
selectedSlotId: selectionAlive ? next.selectedSlotId : "",
|
|
545
|
-
selectedLine: selectionAlive ? next.selectedLine : "",
|
|
561
|
+
selectedSlotId: startRow ? startRow.id : selectionAlive ? next.selectedSlotId : "",
|
|
562
|
+
selectedLine: startRow ? [startRow.day, startRow.title, startRow.subtitle].filter(Boolean).join(" · ") : selectionAlive ? next.selectedLine : "",
|
|
546
563
|
status: "ready",
|
|
547
564
|
errored: false,
|
|
548
565
|
serviceName: serviceRes.service?.name || copyParam("time.serviceNameFallback", "Choose a time"),
|
|
@@ -567,6 +584,7 @@ const svc = defineService<S, {
|
|
|
567
584
|
selectDay: (s, arg) => {
|
|
568
585
|
const day = chosenDay(arg);
|
|
569
586
|
if (!day || !s.days.some((d) => d.id === day)) return derive(s);
|
|
587
|
+
pendingStart = "";
|
|
570
588
|
return derive(withDay(s, day));
|
|
571
589
|
},
|
|
572
590
|
refreshMember: (s, arg) => { stashCopy(arg); return probeMember(s); },
|
|
@@ -575,6 +593,7 @@ const svc = defineService<S, {
|
|
|
575
593
|
? arg
|
|
576
594
|
: ((arg as { id?: string; slotId?: string })?.slotId ?? (arg as { id?: string })?.id ?? "");
|
|
577
595
|
const selected = allRows.find((slot) => slot.id === slotId);
|
|
596
|
+
if (selected) pendingStart = "";
|
|
578
597
|
return derive({
|
|
579
598
|
...s,
|
|
580
599
|
selectedSlotId: selected ? slotId : s.selectedSlotId,
|
|
@@ -638,12 +657,14 @@ const svc = defineService<S, {
|
|
|
638
657
|
if (!slot) return derive({ ...s, message: copyParam("time.bookSlotGone", "That time is no longer available. Try again.") });
|
|
639
658
|
// A confirmed booking is reported to the app hosting this screen (an embed's `onAction`), so a
|
|
640
659
|
// host chat can say so and refresh what it shows. Dropped where nothing hosts the screen.
|
|
641
|
-
|
|
642
|
-
|
|
660
|
+
// The booking's own instants carry an offset; a slot's may be LOCAL to its timezone, which
|
|
661
|
+
// `emitHostEvent` leaves out rather than let the host read it in its own zone.
|
|
662
|
+
const reportBooked = (booking: { id?: string; startDate?: string; endDate?: string } | undefined) => emitHostEvent("booking.created", {
|
|
663
|
+
bookingId: booking?.id,
|
|
643
664
|
serviceId: s.id,
|
|
644
665
|
serviceName: s.serviceName,
|
|
645
|
-
start: slot.startDate,
|
|
646
|
-
end: slot.endDate,
|
|
666
|
+
start: hostEventInstant(booking?.startDate) || slot.startDate,
|
|
667
|
+
end: hostEventInstant(booking?.endDate) || slot.endDate,
|
|
647
668
|
});
|
|
648
669
|
// PAID service — FULLY NATIVE in-app payment (mirrors Wix's RN apps): NO
|
|
649
670
|
// hosted-checkout WebView / browser redirect. The chain is
|
|
@@ -679,7 +700,7 @@ const svc = defineService<S, {
|
|
|
679
700
|
const orderRes = hosted ? { paymentGatewayOrderId: "" } : await createOrder(checkoutId);
|
|
680
701
|
const snapshotId = orderRes.paymentGatewayOrderId ?? "";
|
|
681
702
|
if (!snapshotId && !hosted) {
|
|
682
|
-
await reportBooked(
|
|
703
|
+
await reportBooked(created.booking);
|
|
683
704
|
return derive({ ...pending, bookingInFlight: false, successMessage: copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."), message: "" });
|
|
684
705
|
}
|
|
685
706
|
// 4. Collect payment IN-APP — wallet when this app has one wired, otherwise
|
|
@@ -701,7 +722,7 @@ const svc = defineService<S, {
|
|
|
701
722
|
},
|
|
702
723
|
});
|
|
703
724
|
if (pay.kind === "paid") {
|
|
704
|
-
await reportBooked(
|
|
725
|
+
await reportBooked(created.booking);
|
|
705
726
|
return derive({ ...pending, bookingInFlight: false, successMessage: copyParam("time.successMessage", "Booked. Your confirmation is ready in My Bookings."), message: "" });
|
|
706
727
|
}
|
|
707
728
|
if (pay.kind === "handoff") {
|
|
@@ -740,7 +761,7 @@ const svc = defineService<S, {
|
|
|
740
761
|
// Reconcile the optimistic local id with the server-assigned booking id.
|
|
741
762
|
const realId = created.booking?.id;
|
|
742
763
|
if (realId && realId !== localId) { await cacheDrop(localId); await cachePut(localRow(realId)); }
|
|
743
|
-
await reportBooked(
|
|
764
|
+
await reportBooked(created.booking);
|
|
744
765
|
return derive({
|
|
745
766
|
...pending,
|
|
746
767
|
bookingInFlight: false,
|
|
@@ -444,3 +444,24 @@ export function calendarDay(date: Date, timeZone?: string): string {
|
|
|
444
444
|
}
|
|
445
445
|
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
446
446
|
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* [timeZone]'s offset from UTC in ms at instant [at] (Asia/Jerusalem in winter: 7_200_000), or NaN
|
|
450
|
+
* where it cannot be known — no ECMA-402 (the wrist engines) or a zone this engine does not know.
|
|
451
|
+
* Callers fall back to the device zone on NaN, as the formatters above do.
|
|
452
|
+
*/
|
|
453
|
+
export function zoneOffsetAt(at: number, timeZone: string): number {
|
|
454
|
+
if (hasIntl() && Number.isFinite(at)) {
|
|
455
|
+
try {
|
|
456
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
457
|
+
timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit",
|
|
458
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
459
|
+
}).formatToParts(new Date(at));
|
|
460
|
+
const n = (type: string) => Number(parts.find((p) => p.type === type)?.value);
|
|
461
|
+
return Date.UTC(n("year"), n("month") - 1, n("day"), n("hour") % 24, n("minute"), n("second")) - at;
|
|
462
|
+
} catch {
|
|
463
|
+
/* a zone this engine does not know: NaN below */
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return NaN;
|
|
467
|
+
}
|
|
@@ -444,3 +444,24 @@ export function calendarDay(date: Date, timeZone?: string): string {
|
|
|
444
444
|
}
|
|
445
445
|
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
446
446
|
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* [timeZone]'s offset from UTC in ms at instant [at] (Asia/Jerusalem in winter: 7_200_000), or NaN
|
|
450
|
+
* where it cannot be known — no ECMA-402 (the wrist engines) or a zone this engine does not know.
|
|
451
|
+
* Callers fall back to the device zone on NaN, as the formatters above do.
|
|
452
|
+
*/
|
|
453
|
+
export function zoneOffsetAt(at: number, timeZone: string): number {
|
|
454
|
+
if (hasIntl() && Number.isFinite(at)) {
|
|
455
|
+
try {
|
|
456
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
457
|
+
timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit",
|
|
458
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
459
|
+
}).formatToParts(new Date(at));
|
|
460
|
+
const n = (type: string) => Number(parts.find((p) => p.type === type)?.value);
|
|
461
|
+
return Date.UTC(n("year"), n("month") - 1, n("day"), n("hour") % 24, n("minute"), n("second")) - at;
|
|
462
|
+
} catch {
|
|
463
|
+
/* a zone this engine does not know: NaN below */
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return NaN;
|
|
467
|
+
}
|
|
@@ -444,3 +444,24 @@ export function calendarDay(date: Date, timeZone?: string): string {
|
|
|
444
444
|
}
|
|
445
445
|
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
446
446
|
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* [timeZone]'s offset from UTC in ms at instant [at] (Asia/Jerusalem in winter: 7_200_000), or NaN
|
|
450
|
+
* where it cannot be known — no ECMA-402 (the wrist engines) or a zone this engine does not know.
|
|
451
|
+
* Callers fall back to the device zone on NaN, as the formatters above do.
|
|
452
|
+
*/
|
|
453
|
+
export function zoneOffsetAt(at: number, timeZone: string): number {
|
|
454
|
+
if (hasIntl() && Number.isFinite(at)) {
|
|
455
|
+
try {
|
|
456
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
457
|
+
timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit",
|
|
458
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
459
|
+
}).formatToParts(new Date(at));
|
|
460
|
+
const n = (type: string) => Number(parts.find((p) => p.type === type)?.value);
|
|
461
|
+
return Date.UTC(n("year"), n("month") - 1, n("day"), n("hour") % 24, n("minute"), n("second")) - at;
|
|
462
|
+
} catch {
|
|
463
|
+
/* a zone this engine does not know: NaN below */
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return NaN;
|
|
467
|
+
}
|
|
@@ -444,3 +444,24 @@ export function calendarDay(date: Date, timeZone?: string): string {
|
|
|
444
444
|
}
|
|
445
445
|
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
446
446
|
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* [timeZone]'s offset from UTC in ms at instant [at] (Asia/Jerusalem in winter: 7_200_000), or NaN
|
|
450
|
+
* where it cannot be known — no ECMA-402 (the wrist engines) or a zone this engine does not know.
|
|
451
|
+
* Callers fall back to the device zone on NaN, as the formatters above do.
|
|
452
|
+
*/
|
|
453
|
+
export function zoneOffsetAt(at: number, timeZone: string): number {
|
|
454
|
+
if (hasIntl() && Number.isFinite(at)) {
|
|
455
|
+
try {
|
|
456
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
457
|
+
timeZone, hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit",
|
|
458
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
459
|
+
}).formatToParts(new Date(at));
|
|
460
|
+
const n = (type: string) => Number(parts.find((p) => p.type === type)?.value);
|
|
461
|
+
return Date.UTC(n("year"), n("month") - 1, n("day"), n("hour") % 24, n("minute"), n("second")) - at;
|
|
462
|
+
} catch {
|
|
463
|
+
/* a zone this engine does not know: NaN below */
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return NaN;
|
|
467
|
+
}
|