@colabcommerce/elements 0.0.4 → 0.9.1
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/.pnp.cjs +16484 -0
- package/.pnp.loader.mjs +2126 -0
- package/.yarn/install-state.gz +0 -0
- package/.yarn/releases/yarn-4.12.0.cjs +942 -0
- package/.yarnrc.yml +1 -0
- package/README.md +60 -41
- package/cypress/fixtures/example.json +5 -0
- package/cypress/support/commands.js +25 -0
- package/cypress/support/component-index.html +15 -0
- package/cypress/support/component.js +26 -0
- package/cypress.config.js +10 -0
- package/eslint.config.js +32 -0
- package/index.html +13 -0
- package/package.json +91 -67
- package/playground/index.html +14 -0
- package/playground/main.jsx +36 -0
- package/public/vite.svg +1 -0
- package/src/App.css +0 -0
- package/src/App.jsx +65 -0
- package/src/components/CollapsibleStoreHours/index.jsx +269 -0
- package/src/components/HoursList/index.jsx +225 -0
- package/src/components/LeadForm/index.jsx +241 -0
- package/src/components/MessageDialog/index.jsx +169 -0
- package/src/components/QuoteForm/index.jsx +82 -0
- package/src/components/QuoteFormSearch/index.jsx +276 -0
- package/src/components/QuoteFormStoreList/index.jsx +65 -0
- package/src/components/QuoteFormStoreListItem/index.jsx +134 -0
- package/src/components/QuoteLeadForm/index.jsx +16 -0
- package/src/components/QuoteMap/index.jsx +96 -0
- package/src/components/QuoteMapMarker/index.jsx +56 -0
- package/src/components/StaticMap/index.jsx +24 -0
- package/src/components/Store/index.jsx +44 -0
- package/src/components/StoreContact/index.jsx +96 -0
- package/src/components/StoreInfo/index.jsx +50 -0
- package/src/components/StoreList/index.jsx +59 -0
- package/src/components/StoreListItem/index.jsx +99 -0
- package/src/components/StoreListItem/indexStoreListItem.cy.jsx +30 -0
- package/src/components/StoreListNoneFound/index.jsx +16 -0
- package/src/components/StoreLocator/index.jsx +43 -0
- package/src/components/StoreLocatorMap/index.jsx +93 -0
- package/src/components/StoreLocatorMapMarker/index.jsx +55 -0
- package/src/components/StoreLocatorMessageDialog/index.jsx +20 -0
- package/src/components/StoreLocatorSearch/index.jsx +316 -0
- package/src/components/StoreMap/index.jsx +30 -0
- package/src/components/StoreMeta/index.jsx +7 -0
- package/src/components/StoreProducts/index.jsx +112 -0
- package/src/components/ui/Badge/index.jsx +46 -0
- package/src/components/ui/Button/index.jsx +56 -0
- package/src/components/ui/Button/indexButton.cy.jsx +9 -0
- package/src/components/ui/Card/index.jsx +90 -0
- package/src/components/ui/Input/index.jsx +19 -0
- package/src/components/ui/Input/indexInput.cy.jsx +9 -0
- package/src/components/ui/LoadingPuff/index.jsx +10 -0
- package/src/components/ui/Panel/index.jsx +23 -0
- package/src/components/ui/PhoneNumberInput/index.jsx +17 -0
- package/src/contexts/quote-form.jsx +94 -0
- package/src/contexts/store-locator.jsx +83 -0
- package/src/contexts/store.jsx +59 -0
- package/src/contexts/translations.jsx +11 -0
- package/src/dist.css +229 -0
- package/src/entries/QuoteForm.js +2 -0
- package/src/entries/Store.js +2 -0
- package/src/entries/StoreLocator.js +2 -0
- package/src/entries/StoreLocatorProvider.js +2 -0
- package/src/entries/styles.js +2 -0
- package/src/entries/useStoreLocator.js +2 -0
- package/src/i18n/defaultResources.js +19 -0
- package/src/i18n/index.js +44 -0
- package/src/i18n/mergeResources.js +22 -0
- package/src/index.css +214 -0
- package/src/lib/addressComponentsToAddress.js +43 -0
- package/src/lib/productSchema.js +6 -0
- package/src/lib/useGeolocation.js +266 -0
- package/src/lib/useHours.js +205 -0
- package/src/lib/usePlacesAutocomplete.js +288 -0
- package/src/lib/useProductAvailability.js +38 -0
- package/src/lib/useRudderAnalytics.js +50 -0
- package/src/lib/useSearchResults.js +102 -0
- package/src/lib/useStoreLocatorConfig.js +50 -0
- package/src/lib/utils/cn.js +6 -0
- package/src/lib/utils/measure.js +31 -0
- package/src/locales/en/default.json +58 -0
- package/src/locales/es/default.json +58 -0
- package/src/locales/fr/default.json +58 -0
- package/src/locales/it/default.json +58 -0
- package/src/main.jsx +10 -0
- package/vite.config.js +60 -53
- package/dist/CartForm.js +0 -617
- package/dist/Container-CU_WrBOi.js +0 -22
- package/dist/Modal-DTBKy_6d.js +0 -863
- package/dist/ProductForm.js +0 -343
- package/dist/Retailer.js +0 -3637
- package/dist/StoreLocator.js +0 -797
- package/dist/addressComponentsToAddress-DCL-K8mn.js +0 -1932
- package/dist/browser-ponyfill-DcK7_cJB.js +0 -339
- package/dist/globals-B8-hYoIU.js +0 -8518
- package/dist/index-CqSfhXDd.js +0 -137
- package/dist/index-FM02Uq_P.js +0 -100
- package/dist/style.css +0 -1
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import * as Collapsible from "@radix-ui/react-collapsible";
|
|
3
|
+
import { ChevronDown } from "lucide-react";
|
|
4
|
+
import { useTranslation } from "react-i18next";
|
|
5
|
+
|
|
6
|
+
const DAY_LABELS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
7
|
+
|
|
8
|
+
export default function StoreHoursCollapsible({
|
|
9
|
+
hours,
|
|
10
|
+
openingSoonMinutes = 60,
|
|
11
|
+
closingSoonMinutes = 60,
|
|
12
|
+
className = "",
|
|
13
|
+
}) {
|
|
14
|
+
const { t } = useTranslation();
|
|
15
|
+
const [open, setOpen] = React.useState(false);
|
|
16
|
+
|
|
17
|
+
const computed = React.useMemo(() => {
|
|
18
|
+
const safeHours = Array.isArray(hours) ? hours : [];
|
|
19
|
+
const storeTz =
|
|
20
|
+
safeHours.find((h) => h?.timezone)?.timezone ||
|
|
21
|
+
Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
22
|
+
|
|
23
|
+
const now = new Date();
|
|
24
|
+
const storeWeekdayIndex = getWeekdayIndexInTimeZone(now, storeTz); // 0..6
|
|
25
|
+
const today = safeHours.find((h) => h?.day === storeWeekdayIndex);
|
|
26
|
+
|
|
27
|
+
const status = computeStatus({
|
|
28
|
+
now,
|
|
29
|
+
today,
|
|
30
|
+
openingSoonMinutes,
|
|
31
|
+
closingSoonMinutes,
|
|
32
|
+
t,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Display Monday..Sunday
|
|
36
|
+
const displayOrder = [1, 2, 3, 4, 5, 6, 0];
|
|
37
|
+
const byDay = new Map(safeHours.map((h) => [h.day, h]));
|
|
38
|
+
const rows = displayOrder.map((dayIdx) => {
|
|
39
|
+
const h = byDay.get(dayIdx);
|
|
40
|
+
const label = t(`default:${DAY_LABELS[dayIdx].toLowerCase()}`);
|
|
41
|
+
const isToday = dayIdx === storeWeekdayIndex;
|
|
42
|
+
|
|
43
|
+
let text = t("default:closed");
|
|
44
|
+
if (h?.open) {
|
|
45
|
+
const openDate = buildOpenDateForDisplay(h);
|
|
46
|
+
const closeDate = buildCloseDateForDisplay(h, openDate);
|
|
47
|
+
if (openDate && closeDate) {
|
|
48
|
+
text = `${formatTimeLocal(openDate)} - ${formatTimeLocal(closeDate)}`;
|
|
49
|
+
} else {
|
|
50
|
+
text = t("default:open");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { dayIdx, label, text, isToday };
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
statusLabel: t(`default:${status.label.toLowerCase().replace(" ", "_")}`), // "Closed" | "Opening Soon" | "Open" | "Closing Soon"
|
|
59
|
+
statusDetail: status.detail, // "Opens at 9am" | "Closes at 4:30pm" | ""
|
|
60
|
+
rows,
|
|
61
|
+
};
|
|
62
|
+
}, [hours, openingSoonMinutes, closingSoonMinutes]);
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className={className}>
|
|
66
|
+
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
|
67
|
+
<Collapsible.Trigger asChild>
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
className={[
|
|
71
|
+
"group w-full flex items-center justify-between gap-3",
|
|
72
|
+
"rounded-lg bg-white py-2 px-3",
|
|
73
|
+
"text-sm text-muted-foreground",
|
|
74
|
+
"hover:bg-black/[0.02] active:bg-black/[0.04]",
|
|
75
|
+
"focus:outline-none",
|
|
76
|
+
].join(" ")}
|
|
77
|
+
aria-expanded={open}
|
|
78
|
+
>
|
|
79
|
+
<div className="flex flex-col items-start">
|
|
80
|
+
<span className="text-muted-foreground leading-tight">{computed.statusLabel}</span>
|
|
81
|
+
{computed.statusDetail ? (
|
|
82
|
+
<span className="text-xs text-muted-foreground/80 leading-tight">
|
|
83
|
+
{computed.statusDetail}
|
|
84
|
+
</span>
|
|
85
|
+
) : null}
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<ChevronDown
|
|
89
|
+
className={[
|
|
90
|
+
"h-4 w-4 text-black/60",
|
|
91
|
+
"transition-transform duration-200 ease-out",
|
|
92
|
+
open ? "rotate-180" : "rotate-0",
|
|
93
|
+
].join(" ")}
|
|
94
|
+
aria-hidden="true"
|
|
95
|
+
/>
|
|
96
|
+
</button>
|
|
97
|
+
</Collapsible.Trigger>
|
|
98
|
+
|
|
99
|
+
<Collapsible.Content
|
|
100
|
+
className={[
|
|
101
|
+
"mt-2 bg-white p-2",
|
|
102
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
|
103
|
+
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
104
|
+
"data-[state=closed]:slide-out-to-top-1 data-[state=open]:slide-in-from-top-1",
|
|
105
|
+
].join(" ")}
|
|
106
|
+
>
|
|
107
|
+
<div className="grid gap-1.5">
|
|
108
|
+
{computed.rows.map((row) => (
|
|
109
|
+
<div
|
|
110
|
+
key={row.dayIdx}
|
|
111
|
+
className={[
|
|
112
|
+
"flex items-center justify-between gap-3",
|
|
113
|
+
"px-2.5 py-1 text-sm",
|
|
114
|
+
row.isToday ? "text-primary font-semibold" : "border-black/5 bg-transparent",
|
|
115
|
+
].join(" ")}
|
|
116
|
+
aria-current={row.isToday ? "date" : undefined}
|
|
117
|
+
>
|
|
118
|
+
<span>{row.label}</span>
|
|
119
|
+
<span>{row.text}</span>
|
|
120
|
+
</div>
|
|
121
|
+
))}
|
|
122
|
+
</div>
|
|
123
|
+
</Collapsible.Content>
|
|
124
|
+
</Collapsible.Root>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Toggle label logic based on today's schedule (store timezone for "today",
|
|
131
|
+
* comparisons against the user's current instant).
|
|
132
|
+
*
|
|
133
|
+
* Adds a detail line:
|
|
134
|
+
* - Closed / Opening Soon -> "Opens at X"
|
|
135
|
+
* - Open / Closing Soon -> "Closes at Y"
|
|
136
|
+
*/
|
|
137
|
+
function computeStatus({ now, today, openingSoonMinutes, closingSoonMinutes, t }) {
|
|
138
|
+
if (!today || !today.open) return { label: t('default:closed'), detail: "" };
|
|
139
|
+
|
|
140
|
+
const openDate = buildOpenDateForDisplay(today);
|
|
141
|
+
const closeDate = buildCloseDateForDisplay(today, openDate);
|
|
142
|
+
|
|
143
|
+
// If schedule incomplete, still show basic label
|
|
144
|
+
if (!openDate || !closeDate) return { label: t('default:open'), detail: "" };
|
|
145
|
+
|
|
146
|
+
const nowMs = now.getTime();
|
|
147
|
+
const openMs = openDate.getTime();
|
|
148
|
+
const closeMs = closeDate.getTime();
|
|
149
|
+
|
|
150
|
+
const openingSoonStart = openMs - openingSoonMinutes * 60 * 1000;
|
|
151
|
+
const closingSoonStart = closeMs - closingSoonMinutes * 60 * 1000;
|
|
152
|
+
|
|
153
|
+
const opensDetail = t('default:opens_at', { time: formatTimeLocal(openDate) });
|
|
154
|
+
const closesDetail = t('default:closes_at', { time: formatTimeLocal(closeDate) });
|
|
155
|
+
|
|
156
|
+
if (nowMs < openMs) {
|
|
157
|
+
if (nowMs >= openingSoonStart) return { label: t('default:opening_soon'), detail: opensDetail };
|
|
158
|
+
return { label: t('default:closed'), detail: opensDetail };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (nowMs >= openMs && nowMs < closeMs) {
|
|
162
|
+
if (nowMs >= closingSoonStart) return { label: t('default:closing_soon'), detail: closesDetail };
|
|
163
|
+
return { label: t('default:open'), detail: closesDetail };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { label: t('default:closed'), detail: opensDetail };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Returns weekday index (0=Sun..6=Sat) for a Date, as seen in `timeZone`. */
|
|
170
|
+
function getWeekdayIndexInTimeZone(date, timeZone) {
|
|
171
|
+
const weekday = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone }).format(date);
|
|
172
|
+
switch (weekday) {
|
|
173
|
+
case "Sun":
|
|
174
|
+
return 0;
|
|
175
|
+
case "Mon":
|
|
176
|
+
return 1;
|
|
177
|
+
case "Tue":
|
|
178
|
+
return 2;
|
|
179
|
+
case "Wed":
|
|
180
|
+
return 3;
|
|
181
|
+
case "Thu":
|
|
182
|
+
return 4;
|
|
183
|
+
case "Fri":
|
|
184
|
+
return 5;
|
|
185
|
+
case "Sat":
|
|
186
|
+
return 6;
|
|
187
|
+
default:
|
|
188
|
+
return new Date(date).getDay();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Build "open" Date (instant) for display in user's local time.
|
|
194
|
+
* Prefer `open_at` because it encodes offset and is DST-correct for that date.
|
|
195
|
+
*/
|
|
196
|
+
function buildOpenDateForDisplay(h) {
|
|
197
|
+
if (!h) return null;
|
|
198
|
+
|
|
199
|
+
if (h.open_at) {
|
|
200
|
+
const d = new Date(h.open_at);
|
|
201
|
+
return isValidDate(d) ? d : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (typeof h.open_at_hour === "number" && typeof h.open_at_minute === "number") {
|
|
205
|
+
return buildDateFromLocalStoreTime({
|
|
206
|
+
storeHour: h.open_at_hour,
|
|
207
|
+
storeMinute: h.open_at_minute,
|
|
208
|
+
utcOffsetMinute: h.utc_offset_minute,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Build "close" Date (instant). If close <= open, assume next day.
|
|
217
|
+
*/
|
|
218
|
+
function buildCloseDateForDisplay(h, openDate) {
|
|
219
|
+
if (!h) return null;
|
|
220
|
+
|
|
221
|
+
if (typeof h.close_at_hour !== "number" || typeof h.close_at_minute !== "number") return null;
|
|
222
|
+
|
|
223
|
+
let closeDate = buildDateFromLocalStoreTime({
|
|
224
|
+
storeHour: h.close_at_hour,
|
|
225
|
+
storeMinute: h.close_at_minute,
|
|
226
|
+
utcOffsetMinute: h.utc_offset_minute,
|
|
227
|
+
anchorDate: openDate || undefined,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
if (!closeDate || !openDate) return closeDate;
|
|
231
|
+
|
|
232
|
+
if (closeDate.getTime() <= openDate.getTime()) {
|
|
233
|
+
closeDate = new Date(closeDate.getTime() + 24 * 60 * 60 * 1000);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return closeDate;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Build an instant from a store-local time-of-day plus a fixed UTC offset.
|
|
241
|
+
* storeLocal = UTC + offset => UTC = storeLocal - offset
|
|
242
|
+
*/
|
|
243
|
+
function buildDateFromLocalStoreTime({ storeHour, storeMinute, utcOffsetMinute, anchorDate }) {
|
|
244
|
+
const base = anchorDate ? new Date(anchorDate) : new Date();
|
|
245
|
+
const y = base.getFullYear();
|
|
246
|
+
const m = base.getMonth();
|
|
247
|
+
const d = base.getDate();
|
|
248
|
+
|
|
249
|
+
const utcMillis = Date.UTC(y, m, d, storeHour, storeMinute, 0) - utcOffsetMinute * 60 * 1000;
|
|
250
|
+
const out = new Date(utcMillis);
|
|
251
|
+
|
|
252
|
+
return isValidDate(out) ? out : null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function isValidDate(d) {
|
|
256
|
+
return d instanceof Date && !Number.isNaN(d.getTime());
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Format in user's local timezone as "9am" or "4:30pm". */
|
|
260
|
+
function formatTimeLocal(date) {
|
|
261
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
262
|
+
hour: "numeric",
|
|
263
|
+
minute: "2-digit",
|
|
264
|
+
})
|
|
265
|
+
.format(date)
|
|
266
|
+
.replace(":00", "")
|
|
267
|
+
.replace(" AM", "am")
|
|
268
|
+
.replace(" PM", "pm");
|
|
269
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import * as Collapsible from "@radix-ui/react-collapsible";
|
|
3
|
+
import { ChevronDown } from "lucide-react";
|
|
4
|
+
import { useTranslation } from "react-i18next";
|
|
5
|
+
|
|
6
|
+
const DAY_LABELS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
7
|
+
|
|
8
|
+
export default function HoursList({
|
|
9
|
+
hours,
|
|
10
|
+
openingSoonMinutes = 60,
|
|
11
|
+
closingSoonMinutes = 60,
|
|
12
|
+
className = "",
|
|
13
|
+
}) {
|
|
14
|
+
const { t } = useTranslation();
|
|
15
|
+
const [open, setOpen] = React.useState(false);
|
|
16
|
+
|
|
17
|
+
const computed = React.useMemo(() => {
|
|
18
|
+
const safeHours = Array.isArray(hours) ? hours : [];
|
|
19
|
+
const storeTz =
|
|
20
|
+
safeHours.find((h) => h?.timezone)?.timezone ||
|
|
21
|
+
Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
22
|
+
|
|
23
|
+
const now = new Date();
|
|
24
|
+
const storeWeekdayIndex = getWeekdayIndexInTimeZone(now, storeTz); // 0..6
|
|
25
|
+
const today = safeHours.find((h) => h?.day === storeWeekdayIndex);
|
|
26
|
+
|
|
27
|
+
const status = computeStatus({
|
|
28
|
+
now,
|
|
29
|
+
today,
|
|
30
|
+
openingSoonMinutes,
|
|
31
|
+
closingSoonMinutes,
|
|
32
|
+
t,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Display Monday..Sunday
|
|
36
|
+
const displayOrder = [1, 2, 3, 4, 5, 6, 0];
|
|
37
|
+
const byDay = new Map(safeHours.map((h) => [h.day, h]));
|
|
38
|
+
const rows = displayOrder.map((dayIdx) => {
|
|
39
|
+
const h = byDay.get(dayIdx);
|
|
40
|
+
const label = t(`default:${DAY_LABELS[dayIdx].toLowerCase()}`);
|
|
41
|
+
const isToday = dayIdx === storeWeekdayIndex;
|
|
42
|
+
|
|
43
|
+
let text = t("default:closed");
|
|
44
|
+
if (h?.open) {
|
|
45
|
+
const openDate = buildOpenDateForDisplay(h);
|
|
46
|
+
const closeDate = buildCloseDateForDisplay(h, openDate);
|
|
47
|
+
if (openDate && closeDate) {
|
|
48
|
+
text = `${formatTimeLocal(openDate)} - ${formatTimeLocal(closeDate)}`;
|
|
49
|
+
} else {
|
|
50
|
+
text = t("default:open");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { dayIdx, label, text, isToday };
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
statusLabel: t(`default:${status.label.toLowerCase().replace(" ", "_")}`), // "Closed" | "Opening Soon" | "Open" | "Closing Soon"
|
|
59
|
+
statusDetail: status.detail, // "Opens at 9am" | "Closes at 4:30pm" | ""
|
|
60
|
+
rows,
|
|
61
|
+
};
|
|
62
|
+
}, [hours, openingSoonMinutes, closingSoonMinutes]);
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
{computed.rows.map((row) => (
|
|
67
|
+
<>
|
|
68
|
+
<div
|
|
69
|
+
key={row.dayIdx}
|
|
70
|
+
className={`flex justify-between items-center py-2 ${row.isToday ? "bg-primary/5 -mx-4 px-4 rounded-lg" : ""
|
|
71
|
+
}`}
|
|
72
|
+
>
|
|
73
|
+
<span className={`font-medium ${row.isToday ? "text-primary" : ""}`}>
|
|
74
|
+
{row.label}
|
|
75
|
+
{row.isToday && <span className="ml-2 text-xs">(Today)</span>}
|
|
76
|
+
</span>
|
|
77
|
+
<span className="text-muted-foreground">{row.text}</span>
|
|
78
|
+
</div>
|
|
79
|
+
</>
|
|
80
|
+
))}
|
|
81
|
+
</>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Toggle label logic based on today's schedule (store timezone for "today",
|
|
87
|
+
* comparisons against the user's current instant).
|
|
88
|
+
*
|
|
89
|
+
* Adds a detail line:
|
|
90
|
+
* - Closed / Opening Soon -> "Opens at X"
|
|
91
|
+
* - Open / Closing Soon -> "Closes at Y"
|
|
92
|
+
*/
|
|
93
|
+
function computeStatus({ now, today, openingSoonMinutes, closingSoonMinutes, t }) {
|
|
94
|
+
if (!today || !today.open) return { label: t('default:closed'), detail: "" };
|
|
95
|
+
|
|
96
|
+
const openDate = buildOpenDateForDisplay(today);
|
|
97
|
+
const closeDate = buildCloseDateForDisplay(today, openDate);
|
|
98
|
+
|
|
99
|
+
// If schedule incomplete, still show basic label
|
|
100
|
+
if (!openDate || !closeDate) return { label: t('default:open'), detail: "" };
|
|
101
|
+
|
|
102
|
+
const nowMs = now.getTime();
|
|
103
|
+
const openMs = openDate.getTime();
|
|
104
|
+
const closeMs = closeDate.getTime();
|
|
105
|
+
|
|
106
|
+
const openingSoonStart = openMs - openingSoonMinutes * 60 * 1000;
|
|
107
|
+
const closingSoonStart = closeMs - closingSoonMinutes * 60 * 1000;
|
|
108
|
+
|
|
109
|
+
const opensDetail = t('default:opens_at', { time: formatTimeLocal(openDate) });
|
|
110
|
+
const closesDetail = t('default:closes_at', { time: formatTimeLocal(closeDate) });
|
|
111
|
+
|
|
112
|
+
if (nowMs < openMs) {
|
|
113
|
+
if (nowMs >= openingSoonStart) return { label: t('default:opening_soon'), detail: opensDetail };
|
|
114
|
+
return { label: t('default:closed'), detail: opensDetail };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (nowMs >= openMs && nowMs < closeMs) {
|
|
118
|
+
if (nowMs >= closingSoonStart) return { label: t('default:closing_soon'), detail: closesDetail };
|
|
119
|
+
return { label: t('default:open'), detail: closesDetail };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { label: t('default:closed'), detail: opensDetail };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Returns weekday index (0=Sun..6=Sat) for a Date, as seen in `timeZone`. */
|
|
126
|
+
function getWeekdayIndexInTimeZone(date, timeZone) {
|
|
127
|
+
const weekday = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone }).format(date);
|
|
128
|
+
switch (weekday) {
|
|
129
|
+
case "Sun":
|
|
130
|
+
return 0;
|
|
131
|
+
case "Mon":
|
|
132
|
+
return 1;
|
|
133
|
+
case "Tue":
|
|
134
|
+
return 2;
|
|
135
|
+
case "Wed":
|
|
136
|
+
return 3;
|
|
137
|
+
case "Thu":
|
|
138
|
+
return 4;
|
|
139
|
+
case "Fri":
|
|
140
|
+
return 5;
|
|
141
|
+
case "Sat":
|
|
142
|
+
return 6;
|
|
143
|
+
default:
|
|
144
|
+
return new Date(date).getDay();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Build "open" Date (instant) for display in user's local time.
|
|
150
|
+
* Prefer `open_at` because it encodes offset and is DST-correct for that date.
|
|
151
|
+
*/
|
|
152
|
+
function buildOpenDateForDisplay(h) {
|
|
153
|
+
if (!h) return null;
|
|
154
|
+
|
|
155
|
+
if (h.open_at) {
|
|
156
|
+
const d = new Date(h.open_at);
|
|
157
|
+
return isValidDate(d) ? d : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (typeof h.open_at_hour === "number" && typeof h.open_at_minute === "number") {
|
|
161
|
+
return buildDateFromLocalStoreTime({
|
|
162
|
+
storeHour: h.open_at_hour,
|
|
163
|
+
storeMinute: h.open_at_minute,
|
|
164
|
+
utcOffsetMinute: h.utc_offset_minute,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Build "close" Date (instant). If close <= open, assume next day.
|
|
173
|
+
*/
|
|
174
|
+
function buildCloseDateForDisplay(h, openDate) {
|
|
175
|
+
if (!h) return null;
|
|
176
|
+
|
|
177
|
+
if (typeof h.close_at_hour !== "number" || typeof h.close_at_minute !== "number") return null;
|
|
178
|
+
|
|
179
|
+
let closeDate = buildDateFromLocalStoreTime({
|
|
180
|
+
storeHour: h.close_at_hour,
|
|
181
|
+
storeMinute: h.close_at_minute,
|
|
182
|
+
utcOffsetMinute: h.utc_offset_minute,
|
|
183
|
+
anchorDate: openDate || undefined,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
if (!closeDate || !openDate) return closeDate;
|
|
187
|
+
|
|
188
|
+
if (closeDate.getTime() <= openDate.getTime()) {
|
|
189
|
+
closeDate = new Date(closeDate.getTime() + 24 * 60 * 60 * 1000);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return closeDate;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Build an instant from a store-local time-of-day plus a fixed UTC offset.
|
|
197
|
+
* storeLocal = UTC + offset => UTC = storeLocal - offset
|
|
198
|
+
*/
|
|
199
|
+
function buildDateFromLocalStoreTime({ storeHour, storeMinute, utcOffsetMinute, anchorDate }) {
|
|
200
|
+
const base = anchorDate ? new Date(anchorDate) : new Date();
|
|
201
|
+
const y = base.getFullYear();
|
|
202
|
+
const m = base.getMonth();
|
|
203
|
+
const d = base.getDate();
|
|
204
|
+
|
|
205
|
+
const utcMillis = Date.UTC(y, m, d, storeHour, storeMinute, 0) - utcOffsetMinute * 60 * 1000;
|
|
206
|
+
const out = new Date(utcMillis);
|
|
207
|
+
|
|
208
|
+
return isValidDate(out) ? out : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isValidDate(d) {
|
|
212
|
+
return d instanceof Date && !Number.isNaN(d.getTime());
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Format in user's local timezone as "9am" or "4:30pm". */
|
|
216
|
+
function formatTimeLocal(date) {
|
|
217
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
218
|
+
hour: "numeric",
|
|
219
|
+
minute: "2-digit",
|
|
220
|
+
})
|
|
221
|
+
.format(date)
|
|
222
|
+
.replace(":00", "")
|
|
223
|
+
.replace(" AM", "am")
|
|
224
|
+
.replace(" PM", "pm");
|
|
225
|
+
}
|