@clicka1/booking 0.2.2
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/README.md +171 -0
- package/dist/SalonBookingModal-ZIIKN2O2.js +941 -0
- package/dist/SalonBookingModal-ZIIKN2O2.js.map +1 -0
- package/dist/booking.css +2 -0
- package/dist/chunk-HA7DFBYI.js +548 -0
- package/dist/chunk-HA7DFBYI.js.map +1 -0
- package/dist/index.d.ts +192 -0
- package/dist/index.js +970 -0
- package/dist/index.js.map +1 -0
- package/package.json +37 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { normalizeLocale, enrichServiceCategories, buildServiceCategoryTabs, I18nProvider, useT } from './chunk-HA7DFBYI.js';
|
|
3
|
+
import { lazy, forwardRef, useMemo, createContext, useImperativeHandle, Suspense, useContext, useState, useRef, useEffect, useCallback } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
6
|
+
|
|
7
|
+
// ../../lib/salon-services.ts
|
|
8
|
+
function randomHex(bytes) {
|
|
9
|
+
const buf = new Uint8Array(bytes);
|
|
10
|
+
globalThis.crypto.getRandomValues(buf);
|
|
11
|
+
return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
12
|
+
}
|
|
13
|
+
function pickFirstNonEmptyString(...values) {
|
|
14
|
+
for (const value of values) {
|
|
15
|
+
const text = String(value ?? "").trim();
|
|
16
|
+
if (text) return text;
|
|
17
|
+
}
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
function isUnstableId(id) {
|
|
21
|
+
return /^svc-\d/.test(id);
|
|
22
|
+
}
|
|
23
|
+
function assignUniqueServiceId(candidate, usedIds) {
|
|
24
|
+
const trimmed = candidate.trim();
|
|
25
|
+
if (trimmed && !isUnstableId(trimmed) && !usedIds.has(trimmed)) {
|
|
26
|
+
usedIds.add(trimmed);
|
|
27
|
+
return trimmed;
|
|
28
|
+
}
|
|
29
|
+
let id = randomHex(4);
|
|
30
|
+
while (usedIds.has(id)) id = randomHex(4);
|
|
31
|
+
usedIds.add(id);
|
|
32
|
+
return id;
|
|
33
|
+
}
|
|
34
|
+
function parseSalonServices(raw) {
|
|
35
|
+
if (typeof raw === "string") {
|
|
36
|
+
try {
|
|
37
|
+
raw = JSON.parse(raw);
|
|
38
|
+
} catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
let items = [];
|
|
43
|
+
if (Array.isArray(raw)) {
|
|
44
|
+
items = raw;
|
|
45
|
+
} else if (raw && typeof raw === "object") {
|
|
46
|
+
items = Object.values(raw);
|
|
47
|
+
} else {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
const usedIds = /* @__PURE__ */ new Set();
|
|
51
|
+
const out = [];
|
|
52
|
+
items.forEach((item, index) => {
|
|
53
|
+
const row = item;
|
|
54
|
+
const name = pickFirstNonEmptyString(row.name, row.service_name, row.serviceName, row.title);
|
|
55
|
+
if (!name) return;
|
|
56
|
+
const id = assignUniqueServiceId(String(row.id ?? ""), usedIds);
|
|
57
|
+
const duration = Number(row.duration ?? row.duration_min ?? row.durationMin ?? 30) || 30;
|
|
58
|
+
const priceRaw = row.price ?? row.service_price ?? row.servicePrice;
|
|
59
|
+
const price = priceRaw != null ? Number(priceRaw) : void 0;
|
|
60
|
+
const variants = Array.isArray(row.variants) ? row.variants.map((variant) => {
|
|
61
|
+
if (!variant || typeof variant !== "object") return null;
|
|
62
|
+
const v = variant;
|
|
63
|
+
const label = String(v.label ?? "").trim();
|
|
64
|
+
if (!label) return null;
|
|
65
|
+
const priceNum = Number(v.price ?? NaN);
|
|
66
|
+
if (!Number.isFinite(priceNum)) return null;
|
|
67
|
+
const durationNum = Number(v.duration ?? NaN);
|
|
68
|
+
return {
|
|
69
|
+
label,
|
|
70
|
+
price: Math.max(0, priceNum),
|
|
71
|
+
duration: Number.isFinite(durationNum) ? Math.max(5, Math.round(durationNum)) : void 0
|
|
72
|
+
};
|
|
73
|
+
}).filter((variant) => variant !== null) : void 0;
|
|
74
|
+
const images = Array.isArray(row.images) ? row.images.map((image) => String(image ?? "").trim()).filter(Boolean) : void 0;
|
|
75
|
+
const assignedTeamMemberIds = Array.isArray(row.assignedTeamMemberIds) ? [...new Set(row.assignedTeamMemberIds.map((id2) => String(id2 ?? "").trim()).filter(Boolean))] : void 0;
|
|
76
|
+
const paymentTypeRaw = String(row.payment_type ?? "");
|
|
77
|
+
const paymentType = ["none", "deposit", "full"].includes(paymentTypeRaw) ? paymentTypeRaw : void 0;
|
|
78
|
+
const depositAmountRaw = Number(row.deposit_amount ?? NaN);
|
|
79
|
+
const depositAmount = Number.isFinite(depositAmountRaw) ? Math.max(0, depositAmountRaw) : void 0;
|
|
80
|
+
const cancelPolicyHoursRaw = Number(row.cancel_policy_hours ?? NaN);
|
|
81
|
+
const cancelPolicyHours = Number.isFinite(cancelPolicyHoursRaw) && cancelPolicyHoursRaw > 0 ? cancelPolicyHoursRaw : void 0;
|
|
82
|
+
const cancelPolicyActionRaw = String(row.cancel_policy_action ?? "");
|
|
83
|
+
const cancelPolicyAction = ["full_refund", "keep_deposit", "keep_full"].includes(cancelPolicyActionRaw) ? cancelPolicyActionRaw : void 0;
|
|
84
|
+
out.push({
|
|
85
|
+
id,
|
|
86
|
+
name,
|
|
87
|
+
nameEn: String(row.nameEn ?? "").trim() || void 0,
|
|
88
|
+
description: String(row.description ?? "").trim() || void 0,
|
|
89
|
+
descriptionEn: String(row.descriptionEn ?? "").trim() || void 0,
|
|
90
|
+
category: pickFirstNonEmptyString(
|
|
91
|
+
row.category,
|
|
92
|
+
row.category_name,
|
|
93
|
+
row.categoryName,
|
|
94
|
+
row.service_category,
|
|
95
|
+
row.serviceCategory
|
|
96
|
+
) || void 0,
|
|
97
|
+
price: price != null && Number.isFinite(price) ? price : void 0,
|
|
98
|
+
duration,
|
|
99
|
+
images,
|
|
100
|
+
variants: variants && variants.length > 0 ? variants : void 0,
|
|
101
|
+
...assignedTeamMemberIds && assignedTeamMemberIds.length > 0 ? { assignedTeamMemberIds } : {},
|
|
102
|
+
...paymentType !== void 0 ? { payment_type: paymentType } : {},
|
|
103
|
+
...depositAmount !== void 0 ? { deposit_amount: depositAmount } : {},
|
|
104
|
+
...row.requires_confirmation === true ? { requires_confirmation: true } : {},
|
|
105
|
+
...cancelPolicyHours !== void 0 ? { cancel_policy_hours: cancelPolicyHours } : {},
|
|
106
|
+
...cancelPolicyAction !== void 0 ? { cancel_policy_action: cancelPolicyAction } : {}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
function localizeService(service, locale) {
|
|
112
|
+
if (locale !== "en") return service;
|
|
113
|
+
const name = service.nameEn?.trim() || service.name;
|
|
114
|
+
const description = service.descriptionEn?.trim() || service.description;
|
|
115
|
+
return { ...service, name, description };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ../../lib/salon-opening-hours.ts
|
|
119
|
+
var DAY_NAMES_EN = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
120
|
+
var CLICKA_TO_EN = {
|
|
121
|
+
monday: "Monday",
|
|
122
|
+
tuesday: "Tuesday",
|
|
123
|
+
wednesday: "Wednesday",
|
|
124
|
+
thursday: "Thursday",
|
|
125
|
+
friday: "Friday",
|
|
126
|
+
saturday: "Saturday",
|
|
127
|
+
sunday: "Sunday"
|
|
128
|
+
};
|
|
129
|
+
function mergeOpeningHours(workingHours, openingHoursOverride) {
|
|
130
|
+
const out = {};
|
|
131
|
+
for (const d of DAY_NAMES_EN) out[d] = null;
|
|
132
|
+
const fromOverride = normalizeOpeningOverride(openingHoursOverride);
|
|
133
|
+
if (fromOverride) {
|
|
134
|
+
for (const d of DAY_NAMES_EN) {
|
|
135
|
+
if (fromOverride[d] !== void 0) out[d] = fromOverride[d];
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
if (workingHours && typeof workingHours === "object") {
|
|
140
|
+
for (const [k, v] of Object.entries(workingHours)) {
|
|
141
|
+
const en = CLICKA_TO_EN[k.toLowerCase()];
|
|
142
|
+
if (!en) continue;
|
|
143
|
+
if (!v || v.closed || !v.open || !v.close) out[en] = null;
|
|
144
|
+
else out[en] = { open: v.open, close: v.close };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
function normalizeOpeningOverride(raw) {
|
|
150
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
151
|
+
const o = raw;
|
|
152
|
+
const out = {};
|
|
153
|
+
let any = false;
|
|
154
|
+
for (const d of DAY_NAMES_EN) {
|
|
155
|
+
const v = o[d];
|
|
156
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
157
|
+
const open = String(v.open ?? "");
|
|
158
|
+
const close = String(v.close ?? "");
|
|
159
|
+
if (open && close) {
|
|
160
|
+
out[d] = { open, close };
|
|
161
|
+
any = true;
|
|
162
|
+
} else {
|
|
163
|
+
out[d] = null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return any ? out : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ../../lib/booking-blocks.ts
|
|
171
|
+
function isIsoDate(value) {
|
|
172
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(value);
|
|
173
|
+
}
|
|
174
|
+
function isTime(value) {
|
|
175
|
+
return /^([01]\d|2[0-3]):[0-5]\d$/.test(value);
|
|
176
|
+
}
|
|
177
|
+
function toMinutes(value) {
|
|
178
|
+
const [h, m] = value.split(":").map(Number);
|
|
179
|
+
return h * 60 + m;
|
|
180
|
+
}
|
|
181
|
+
function normalizeBookingBlocks(raw) {
|
|
182
|
+
if (!Array.isArray(raw)) return [];
|
|
183
|
+
const out = [];
|
|
184
|
+
for (const item of raw) {
|
|
185
|
+
if (!item || typeof item !== "object") continue;
|
|
186
|
+
const row = item;
|
|
187
|
+
const date = String(row.date ?? "").trim();
|
|
188
|
+
if (!isIsoDate(date)) continue;
|
|
189
|
+
const allDay = row.allDay === true;
|
|
190
|
+
const start = String(row.start ?? "").trim();
|
|
191
|
+
const end = String(row.end ?? "").trim();
|
|
192
|
+
const note = String(row.note ?? "").trim();
|
|
193
|
+
if (!allDay) {
|
|
194
|
+
if (!isTime(start) || !isTime(end)) continue;
|
|
195
|
+
if (toMinutes(end) <= toMinutes(start)) continue;
|
|
196
|
+
out.push({ date, allDay: false, start, end, ...note ? { note } : {} });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
out.push({ date, allDay: true, ...note ? { note } : {} });
|
|
200
|
+
}
|
|
201
|
+
out.sort((a, b) => `${a.date}-${a.start ?? ""}`.localeCompare(`${b.date}-${b.start ?? ""}`));
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
function isDateBlockedAllDay(blocks, date) {
|
|
205
|
+
return blocks.some((b) => b.date === date && b.allDay);
|
|
206
|
+
}
|
|
207
|
+
function isBlockedForStartTime(blocks, date, startTime, durationMin) {
|
|
208
|
+
if (isDateBlockedAllDay(blocks, date)) return true;
|
|
209
|
+
if (!isTime(startTime)) return false;
|
|
210
|
+
const start = toMinutes(startTime);
|
|
211
|
+
const end = start + Math.max(1, durationMin);
|
|
212
|
+
for (const block of blocks) {
|
|
213
|
+
if (block.date !== date || block.allDay || !block.start || !block.end) continue;
|
|
214
|
+
const blockStart = toMinutes(block.start);
|
|
215
|
+
const blockEnd = toMinutes(block.end);
|
|
216
|
+
const overlaps = start < blockEnd && end > blockStart;
|
|
217
|
+
if (overlaps) return true;
|
|
218
|
+
}
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ../../lib/tracking-events.ts
|
|
223
|
+
var BOOKING_STARTED_KEY = "tracking_booking_started";
|
|
224
|
+
function trackBookingStarted() {
|
|
225
|
+
if (typeof sessionStorage === "undefined") return;
|
|
226
|
+
if (sessionStorage.getItem(BOOKING_STARTED_KEY)) return;
|
|
227
|
+
sessionStorage.setItem(BOOKING_STARTED_KEY, "1");
|
|
228
|
+
window.gtag?.("event", "booking_started");
|
|
229
|
+
}
|
|
230
|
+
function trackBookingCompleted({ serviceName, value, currency = "EUR" } = {}) {
|
|
231
|
+
if (typeof sessionStorage !== "undefined") {
|
|
232
|
+
sessionStorage.removeItem(BOOKING_STARTED_KEY);
|
|
233
|
+
}
|
|
234
|
+
const metaParams = {};
|
|
235
|
+
if (value != null && value > 0) {
|
|
236
|
+
metaParams.value = value;
|
|
237
|
+
metaParams.currency = currency;
|
|
238
|
+
}
|
|
239
|
+
if (serviceName) metaParams.content_name = serviceName;
|
|
240
|
+
window.fbq?.("track", "Schedule", metaParams);
|
|
241
|
+
window.fbq?.("trackCustom", "BookingCompleted", { ...metaParams, service_name: serviceName });
|
|
242
|
+
const ga4Params = {};
|
|
243
|
+
if (value != null && value > 0) {
|
|
244
|
+
ga4Params.value = value;
|
|
245
|
+
ga4Params.currency = currency;
|
|
246
|
+
}
|
|
247
|
+
if (serviceName) ga4Params.service_name = serviceName;
|
|
248
|
+
window.gtag?.("event", "booking_completed", ga4Params);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ../../components/booking/useBookingFlow.ts
|
|
252
|
+
var DAY_KEYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
253
|
+
function pad(n) {
|
|
254
|
+
return String(n).padStart(2, "0");
|
|
255
|
+
}
|
|
256
|
+
function toLocalISODate(d) {
|
|
257
|
+
return new Date(d.getTime() - d.getTimezoneOffset() * 6e4).toISOString().split("T")[0];
|
|
258
|
+
}
|
|
259
|
+
function useBookingFlow({
|
|
260
|
+
slug,
|
|
261
|
+
openingHours,
|
|
262
|
+
bookingBlocks,
|
|
263
|
+
slotIntervalMin,
|
|
264
|
+
bookingAdvanceDays,
|
|
265
|
+
bookingServices,
|
|
266
|
+
engineUrl = "",
|
|
267
|
+
successUrl,
|
|
268
|
+
cancelUrl,
|
|
269
|
+
locale = "bg-BG",
|
|
270
|
+
onEvent
|
|
271
|
+
}) {
|
|
272
|
+
const t = useT();
|
|
273
|
+
const api = engineUrl.replace(/\/$/, "");
|
|
274
|
+
const slugPath = `/api/public/v1/salons/${encodeURIComponent(slug)}`;
|
|
275
|
+
const [bookingOpen, setBookingOpen] = useState(false);
|
|
276
|
+
const [selectedServiceIdxs, setSelectedServiceIdxs] = useState([]);
|
|
277
|
+
const [selectedDate, setSelectedDate] = useState("");
|
|
278
|
+
const [selectedTime, setSelectedTime] = useState("");
|
|
279
|
+
const [occupiedByDate, setOccupiedByDate] = useState({});
|
|
280
|
+
const [minDate, setMinDate] = useState("");
|
|
281
|
+
const [maxDate, setMaxDate] = useState("");
|
|
282
|
+
const [clientName, setClientName] = useState("");
|
|
283
|
+
const [clientPhone, setClientPhone] = useState("");
|
|
284
|
+
const [clientEmail, setClientEmail] = useState("");
|
|
285
|
+
const [notes, setNotes] = useState("");
|
|
286
|
+
const [staffMembers, setStaffMembers] = useState([]);
|
|
287
|
+
const [selectedStaffMemberId, setSelectedStaffMemberIdState] = useState(null);
|
|
288
|
+
const staffFetchedRef = useRef(false);
|
|
289
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
290
|
+
const [bookingError, setBookingError] = useState("");
|
|
291
|
+
const [bookingSuccess, setBookingSuccess] = useState("");
|
|
292
|
+
const [bookingSuccessDetails, setBookingSuccessDetails] = useState(null);
|
|
293
|
+
useEffect(() => {
|
|
294
|
+
const today = /* @__PURE__ */ new Date();
|
|
295
|
+
setMinDate(toLocalISODate(today));
|
|
296
|
+
const max = new Date(today);
|
|
297
|
+
max.setDate(today.getDate() + Math.max(1, bookingAdvanceDays));
|
|
298
|
+
setMaxDate(toLocalISODate(max));
|
|
299
|
+
}, [bookingAdvanceDays]);
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
if (!bookingOpen || staffFetchedRef.current) return;
|
|
302
|
+
staffFetchedRef.current = true;
|
|
303
|
+
fetch(`${api}${slugPath}/staff`, { cache: "no-store" }).then((r) => r.json()).then((d) => {
|
|
304
|
+
if (Array.isArray(d.staff)) setStaffMembers(d.staff);
|
|
305
|
+
}).catch(() => {
|
|
306
|
+
});
|
|
307
|
+
}, [bookingOpen, slug]);
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
if (!selectedDate) return;
|
|
310
|
+
let cancelled = false;
|
|
311
|
+
const staffParam = selectedStaffMemberId ? `&staffMemberId=${encodeURIComponent(selectedStaffMemberId)}` : "";
|
|
312
|
+
fetch(
|
|
313
|
+
`${api}${slugPath}/slots?date=${encodeURIComponent(selectedDate)}${staffParam}`,
|
|
314
|
+
{ cache: "no-store" }
|
|
315
|
+
).then((r) => r.json()).then((d) => {
|
|
316
|
+
if (cancelled || !Array.isArray(d.occupied)) return;
|
|
317
|
+
const slots = d.occupied.map((x) => ({
|
|
318
|
+
time: String(x?.time ?? ""),
|
|
319
|
+
duration: Math.max(5, Number(x?.duration ?? 30) || 30)
|
|
320
|
+
})).filter((x) => x.time.length >= 4);
|
|
321
|
+
const cacheKey = selectedStaffMemberId ? `${selectedDate}:${selectedStaffMemberId}` : selectedDate;
|
|
322
|
+
if (!cancelled) setOccupiedByDate((prev) => ({ ...prev, [cacheKey]: slots }));
|
|
323
|
+
}).catch(() => {
|
|
324
|
+
});
|
|
325
|
+
return () => {
|
|
326
|
+
cancelled = true;
|
|
327
|
+
};
|
|
328
|
+
}, [slug, selectedDate, selectedStaffMemberId]);
|
|
329
|
+
const selectedServices = useMemo(
|
|
330
|
+
() => selectedServiceIdxs.map((i) => bookingServices[i]).filter((s) => Boolean(s)),
|
|
331
|
+
[selectedServiceIdxs, bookingServices]
|
|
332
|
+
);
|
|
333
|
+
const totalDuration = useMemo(
|
|
334
|
+
() => selectedServices.reduce((sum, s) => sum + (Number(s.duration) || 0), 0),
|
|
335
|
+
[selectedServices]
|
|
336
|
+
);
|
|
337
|
+
const totalPrice = useMemo(
|
|
338
|
+
() => selectedServices.reduce((sum, s) => sum + (Number(s.price) || 0), 0),
|
|
339
|
+
[selectedServices]
|
|
340
|
+
);
|
|
341
|
+
const slotsForDate = useCallback(
|
|
342
|
+
(date, durationMin) => {
|
|
343
|
+
if (!date) return null;
|
|
344
|
+
const dayKey = DAY_KEYS[(/* @__PURE__ */ new Date(`${date}T12:00:00`)).getDay()];
|
|
345
|
+
const h = openingHours[dayKey];
|
|
346
|
+
if (!h?.open || !h?.close) return "closed";
|
|
347
|
+
if (isDateBlockedAllDay(bookingBlocks, date)) return "closed";
|
|
348
|
+
const dur = Math.max(5, durationMin || 30);
|
|
349
|
+
const cacheKey = selectedStaffMemberId ? `${date}:${selectedStaffMemberId}` : date;
|
|
350
|
+
const occupied = occupiedByDate[cacheKey] ?? [];
|
|
351
|
+
const [oh = 0, om = 0] = h.open.split(":").map(Number);
|
|
352
|
+
const [ch = 0, cm = 0] = h.close.split(":").map(Number);
|
|
353
|
+
const start = oh * 60 + om;
|
|
354
|
+
const latestStart = ch * 60 + cm - dur;
|
|
355
|
+
const slots = [];
|
|
356
|
+
for (let t2 = start; t2 <= latestStart; t2 += slotIntervalMin) {
|
|
357
|
+
const slot = `${pad(Math.floor(t2 / 60))}:${pad(t2 % 60)}`;
|
|
358
|
+
const slotEnd = t2 + dur;
|
|
359
|
+
const overlaps = occupied.some(({ time, duration: d }) => {
|
|
360
|
+
const [bh = 0, bm = 0] = time.split(":").map(Number);
|
|
361
|
+
const existStart = bh * 60 + bm;
|
|
362
|
+
const existEnd = existStart + Math.max(5, d);
|
|
363
|
+
return existStart < slotEnd && existEnd > t2;
|
|
364
|
+
});
|
|
365
|
+
if (!overlaps && !isBlockedForStartTime(bookingBlocks, date, slot, dur)) {
|
|
366
|
+
slots.push(slot);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return slots;
|
|
370
|
+
},
|
|
371
|
+
[openingHours, bookingBlocks, occupiedByDate, selectedStaffMemberId, slotIntervalMin]
|
|
372
|
+
);
|
|
373
|
+
const timeSlots = useMemo(
|
|
374
|
+
() => selectedServiceIdxs.length === 0 ? null : slotsForDate(selectedDate, totalDuration || 30),
|
|
375
|
+
[selectedServiceIdxs.length, slotsForDate, selectedDate, totalDuration]
|
|
376
|
+
);
|
|
377
|
+
const open = useCallback((serviceId) => {
|
|
378
|
+
setBookingError("");
|
|
379
|
+
setBookingSuccess("");
|
|
380
|
+
setBookingSuccessDetails(null);
|
|
381
|
+
if (serviceId) {
|
|
382
|
+
const idx = bookingServices.findIndex((s) => s.id === serviceId);
|
|
383
|
+
setSelectedServiceIdxs(idx >= 0 ? [idx] : []);
|
|
384
|
+
} else {
|
|
385
|
+
setSelectedServiceIdxs([]);
|
|
386
|
+
}
|
|
387
|
+
setSelectedDate("");
|
|
388
|
+
setSelectedTime("");
|
|
389
|
+
setClientName("");
|
|
390
|
+
setClientPhone("");
|
|
391
|
+
setClientEmail("");
|
|
392
|
+
setNotes("");
|
|
393
|
+
setBookingOpen(true);
|
|
394
|
+
if (onEvent) onEvent("booking_started");
|
|
395
|
+
else trackBookingStarted();
|
|
396
|
+
}, [bookingServices, onEvent]);
|
|
397
|
+
const close = useCallback(() => {
|
|
398
|
+
setBookingOpen(false);
|
|
399
|
+
setBookingSuccessDetails(null);
|
|
400
|
+
setSelectedStaffMemberIdState(null);
|
|
401
|
+
setSelectedDate("");
|
|
402
|
+
setSelectedTime("");
|
|
403
|
+
}, []);
|
|
404
|
+
const toggleService = useCallback((idx) => {
|
|
405
|
+
setSelectedServiceIdxs((prev) => {
|
|
406
|
+
const has = prev.includes(idx);
|
|
407
|
+
return has ? prev.filter((x) => x !== idx) : [...prev, idx];
|
|
408
|
+
});
|
|
409
|
+
setSelectedTime("");
|
|
410
|
+
}, []);
|
|
411
|
+
const setDate = useCallback((d) => {
|
|
412
|
+
setSelectedDate(d);
|
|
413
|
+
setSelectedTime("");
|
|
414
|
+
}, []);
|
|
415
|
+
const setStaffMemberId = useCallback((id) => {
|
|
416
|
+
setSelectedStaffMemberIdState(id);
|
|
417
|
+
setSelectedDate("");
|
|
418
|
+
setSelectedTime("");
|
|
419
|
+
}, []);
|
|
420
|
+
const markSlotOccupied = useCallback((date, time, duration) => {
|
|
421
|
+
if (!date || !time) return;
|
|
422
|
+
const dur = Math.max(5, Number(duration) || 30);
|
|
423
|
+
setOccupiedByDate((prev) => {
|
|
424
|
+
const day = prev[date] ?? [];
|
|
425
|
+
if (day.some((s) => s.time === time)) return prev;
|
|
426
|
+
return { ...prev, [date]: [...day, { time, duration: dur }] };
|
|
427
|
+
});
|
|
428
|
+
}, []);
|
|
429
|
+
const submit = useCallback(async (e) => {
|
|
430
|
+
e.preventDefault();
|
|
431
|
+
setBookingError("");
|
|
432
|
+
setBookingSuccess("");
|
|
433
|
+
if (selectedServices.length === 0) {
|
|
434
|
+
setBookingError(t("booking.errors.noService"));
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (!clientName.trim()) {
|
|
438
|
+
setBookingError(t("booking.errors.noName"));
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (!clientPhone.trim()) {
|
|
442
|
+
setBookingError(t("booking.errors.noPhone"));
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (!clientEmail.trim()) {
|
|
446
|
+
setBookingError(t("booking.errors.noEmail"));
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (!selectedDate) {
|
|
450
|
+
setBookingError(t("booking.errors.noDate"));
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (!selectedTime) {
|
|
454
|
+
setBookingError(t("booking.errors.noTime"));
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const serviceName = selectedServices.map((s) => s.name).join(" + ");
|
|
458
|
+
const duration = totalDuration || 30;
|
|
459
|
+
const firstSvc = selectedServices[0];
|
|
460
|
+
const paymentType = firstSvc?.payment_type ?? "none";
|
|
461
|
+
const depositAmt = firstSvc?.deposit_amount ?? 0;
|
|
462
|
+
const amountEuros = paymentType === "deposit" ? depositAmt : paymentType === "full" ? totalPrice ?? 0 : 0;
|
|
463
|
+
const requiresPayment = paymentType !== "none" && amountEuros > 0;
|
|
464
|
+
setIsSubmitting(true);
|
|
465
|
+
try {
|
|
466
|
+
const res = await fetch(`${api}${slugPath}/bookings`, {
|
|
467
|
+
method: "POST",
|
|
468
|
+
headers: { "Content-Type": "application/json" },
|
|
469
|
+
body: JSON.stringify({
|
|
470
|
+
clientName: clientName.trim(),
|
|
471
|
+
clientPhone: clientPhone.trim(),
|
|
472
|
+
clientEmail: clientEmail.trim().toLowerCase(),
|
|
473
|
+
serviceName,
|
|
474
|
+
servicePrice: totalPrice,
|
|
475
|
+
serviceDuration: duration,
|
|
476
|
+
date: selectedDate,
|
|
477
|
+
time: selectedTime,
|
|
478
|
+
notes: notes.trim() || void 0,
|
|
479
|
+
requiresPayment,
|
|
480
|
+
staffMemberId: selectedStaffMemberId ?? void 0
|
|
481
|
+
})
|
|
482
|
+
});
|
|
483
|
+
const json = await res.json().catch(() => ({}));
|
|
484
|
+
if (!res.ok) {
|
|
485
|
+
throw new Error(
|
|
486
|
+
json.error ?? (res.status === 500 ? t("booking.errors.saveFailed") : t("booking.errors.httpError", { status: res.status }))
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
const bookingId = json.bookingId ?? json.id;
|
|
490
|
+
if (requiresPayment && bookingId) {
|
|
491
|
+
const payRes = await fetch(`${api}${slugPath}/booking-checkout`, {
|
|
492
|
+
method: "POST",
|
|
493
|
+
headers: { "Content-Type": "application/json" },
|
|
494
|
+
body: JSON.stringify({
|
|
495
|
+
bookingId,
|
|
496
|
+
salonSlug: slug,
|
|
497
|
+
serviceName,
|
|
498
|
+
amountEuros,
|
|
499
|
+
paymentType,
|
|
500
|
+
successUrl,
|
|
501
|
+
cancelUrl
|
|
502
|
+
})
|
|
503
|
+
});
|
|
504
|
+
const payJson = await payRes.json().catch(() => ({}));
|
|
505
|
+
if (!payRes.ok || !payJson.checkoutUrl) {
|
|
506
|
+
throw new Error(payJson.error ?? t("booking.errors.paymentFailed"));
|
|
507
|
+
}
|
|
508
|
+
window.location.href = payJson.checkoutUrl;
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const dateLabel = (/* @__PURE__ */ new Date(`${selectedDate}T12:00:00`)).toLocaleDateString(locale, {
|
|
512
|
+
weekday: "long",
|
|
513
|
+
day: "numeric",
|
|
514
|
+
month: "long"
|
|
515
|
+
});
|
|
516
|
+
markSlotOccupied(selectedDate, selectedTime, duration);
|
|
517
|
+
setBookingSuccessDetails({ serviceName, dateLabel, time: selectedTime });
|
|
518
|
+
setBookingSuccess(`${serviceName} \u2014 ${dateLabel} ${selectedTime}`);
|
|
519
|
+
const eventPayload = {
|
|
520
|
+
serviceName,
|
|
521
|
+
value: totalPrice > 0 ? totalPrice : void 0,
|
|
522
|
+
currency: "EUR"
|
|
523
|
+
};
|
|
524
|
+
if (onEvent) onEvent("booking_completed", eventPayload);
|
|
525
|
+
else trackBookingCompleted(eventPayload);
|
|
526
|
+
} catch (err) {
|
|
527
|
+
setBookingError(err instanceof Error ? err.message : t("booking.errors.generic"));
|
|
528
|
+
} finally {
|
|
529
|
+
setIsSubmitting(false);
|
|
530
|
+
}
|
|
531
|
+
}, [
|
|
532
|
+
slug,
|
|
533
|
+
selectedServices,
|
|
534
|
+
clientName,
|
|
535
|
+
clientPhone,
|
|
536
|
+
clientEmail,
|
|
537
|
+
selectedDate,
|
|
538
|
+
selectedTime,
|
|
539
|
+
notes,
|
|
540
|
+
totalDuration,
|
|
541
|
+
totalPrice,
|
|
542
|
+
selectedStaffMemberId,
|
|
543
|
+
markSlotOccupied,
|
|
544
|
+
successUrl,
|
|
545
|
+
cancelUrl,
|
|
546
|
+
locale,
|
|
547
|
+
onEvent
|
|
548
|
+
]);
|
|
549
|
+
return {
|
|
550
|
+
bookingOpen,
|
|
551
|
+
open,
|
|
552
|
+
close,
|
|
553
|
+
selectedServiceIdxs,
|
|
554
|
+
toggleService,
|
|
555
|
+
totalDuration,
|
|
556
|
+
totalPrice,
|
|
557
|
+
selectedServices,
|
|
558
|
+
selectedDate,
|
|
559
|
+
setDate,
|
|
560
|
+
selectedTime,
|
|
561
|
+
setTime: setSelectedTime,
|
|
562
|
+
timeSlots,
|
|
563
|
+
minDate,
|
|
564
|
+
maxDate,
|
|
565
|
+
clientName,
|
|
566
|
+
setClientName,
|
|
567
|
+
clientPhone,
|
|
568
|
+
setClientPhone,
|
|
569
|
+
clientEmail,
|
|
570
|
+
setClientEmail,
|
|
571
|
+
notes,
|
|
572
|
+
setNotes,
|
|
573
|
+
staffMembers,
|
|
574
|
+
selectedStaffMemberId,
|
|
575
|
+
setStaffMemberId,
|
|
576
|
+
isSubmitting,
|
|
577
|
+
bookingError,
|
|
578
|
+
bookingSuccess,
|
|
579
|
+
bookingSuccessDetails,
|
|
580
|
+
submit
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
var SalonBookingModal = lazy(
|
|
584
|
+
() => import('./SalonBookingModal-ZIIKN2O2.js').then((m) => ({ default: m.SalonBookingModal }))
|
|
585
|
+
);
|
|
586
|
+
function BookingWidgetInner({
|
|
587
|
+
forwardedRef,
|
|
588
|
+
slug,
|
|
589
|
+
openingHours,
|
|
590
|
+
bookingBlocks,
|
|
591
|
+
slotIntervalMin,
|
|
592
|
+
bookingAdvanceDays,
|
|
593
|
+
bookingServices,
|
|
594
|
+
serviceCatalog,
|
|
595
|
+
categoryTabs,
|
|
596
|
+
engineUrl,
|
|
597
|
+
primaryColor,
|
|
598
|
+
accentGradient,
|
|
599
|
+
successUrl,
|
|
600
|
+
cancelUrl,
|
|
601
|
+
locale,
|
|
602
|
+
formatPrice,
|
|
603
|
+
onEvent,
|
|
604
|
+
salonName,
|
|
605
|
+
basePath
|
|
606
|
+
}) {
|
|
607
|
+
const flow = useBookingFlow({
|
|
608
|
+
slug,
|
|
609
|
+
openingHours,
|
|
610
|
+
bookingBlocks,
|
|
611
|
+
slotIntervalMin,
|
|
612
|
+
bookingAdvanceDays,
|
|
613
|
+
bookingServices,
|
|
614
|
+
engineUrl,
|
|
615
|
+
successUrl,
|
|
616
|
+
cancelUrl,
|
|
617
|
+
locale,
|
|
618
|
+
onEvent
|
|
619
|
+
});
|
|
620
|
+
useImperativeHandle(forwardedRef, () => ({
|
|
621
|
+
open: (serviceId) => flow.open(serviceId),
|
|
622
|
+
close: () => flow.close()
|
|
623
|
+
}), [flow.open, flow.close]);
|
|
624
|
+
const firstSelected = flow.selectedServices[0];
|
|
625
|
+
return /* @__PURE__ */ jsx(Suspense, { fallback: null, children: /* @__PURE__ */ jsx(
|
|
626
|
+
SalonBookingModal,
|
|
627
|
+
{
|
|
628
|
+
open: flow.bookingOpen,
|
|
629
|
+
primaryColor,
|
|
630
|
+
accentGradient,
|
|
631
|
+
locale,
|
|
632
|
+
formatPrice,
|
|
633
|
+
serviceCatalog,
|
|
634
|
+
categoryTabs,
|
|
635
|
+
services: bookingServices,
|
|
636
|
+
selectedServiceIdxs: flow.selectedServiceIdxs,
|
|
637
|
+
selectedDate: flow.selectedDate,
|
|
638
|
+
selectedTime: flow.selectedTime,
|
|
639
|
+
totalDuration: flow.totalDuration,
|
|
640
|
+
totalPrice: flow.totalPrice,
|
|
641
|
+
clientName: flow.clientName,
|
|
642
|
+
clientPhone: flow.clientPhone,
|
|
643
|
+
clientEmail: flow.clientEmail,
|
|
644
|
+
notes: flow.notes,
|
|
645
|
+
salonName,
|
|
646
|
+
termsHref: `${basePath}/terms`,
|
|
647
|
+
privacyHref: `${basePath}/privacy`,
|
|
648
|
+
minDate: flow.minDate,
|
|
649
|
+
maxDate: flow.maxDate,
|
|
650
|
+
timeSlots: flow.timeSlots,
|
|
651
|
+
paymentType: firstSelected?.payment_type ?? "none",
|
|
652
|
+
depositAmount: firstSelected?.deposit_amount,
|
|
653
|
+
cancelPolicyHours: firstSelected?.cancel_policy_hours,
|
|
654
|
+
cancelPolicyAction: firstSelected?.cancel_policy_action,
|
|
655
|
+
isSubmitting: flow.isSubmitting,
|
|
656
|
+
bookingError: flow.bookingError,
|
|
657
|
+
bookingSuccess: flow.bookingSuccess,
|
|
658
|
+
bookingSuccessDetails: flow.bookingSuccessDetails,
|
|
659
|
+
staffMembers: flow.staffMembers,
|
|
660
|
+
selectedStaffMemberId: flow.selectedStaffMemberId,
|
|
661
|
+
onClose: flow.close,
|
|
662
|
+
onToggleService: flow.toggleService,
|
|
663
|
+
onDateChange: flow.setDate,
|
|
664
|
+
onTimeChange: flow.setTime,
|
|
665
|
+
onClientNameChange: flow.setClientName,
|
|
666
|
+
onClientPhoneChange: flow.setClientPhone,
|
|
667
|
+
onClientEmailChange: flow.setClientEmail,
|
|
668
|
+
onNotesChange: flow.setNotes,
|
|
669
|
+
onStaffMemberChange: flow.setStaffMemberId,
|
|
670
|
+
onSubmit: flow.submit
|
|
671
|
+
}
|
|
672
|
+
) });
|
|
673
|
+
}
|
|
674
|
+
var BookingWidget = forwardRef(
|
|
675
|
+
function BookingWidget2({ slug, salon, openingHours: openingHoursProp, bookingBlocks: blocksProp, basePath = "", engineUrl = "", accentGradient, successUrl, cancelUrl, locale: localeProp, formatPrice, onEvent }, ref) {
|
|
676
|
+
const openingHours = useMemo(
|
|
677
|
+
() => mergeOpeningHours(
|
|
678
|
+
salon.working_hours,
|
|
679
|
+
salon.opening_hours
|
|
680
|
+
),
|
|
681
|
+
[salon.working_hours, salon.opening_hours]
|
|
682
|
+
);
|
|
683
|
+
const resolvedHours = openingHoursProp ?? openingHours;
|
|
684
|
+
const resolvedBlocks = blocksProp ?? normalizeBookingBlocks(
|
|
685
|
+
salon.opening_hours && typeof salon.opening_hours === "object" ? salon.opening_hours.booking_blocks : null
|
|
686
|
+
);
|
|
687
|
+
const slotIntervalMin = useMemo(() => {
|
|
688
|
+
const oh = salon.opening_hours;
|
|
689
|
+
if (oh && typeof oh === "object") {
|
|
690
|
+
const v = Number(oh.slot_interval_min);
|
|
691
|
+
if ([15, 20, 30, 45, 60].includes(v)) return v;
|
|
692
|
+
}
|
|
693
|
+
return 30;
|
|
694
|
+
}, [salon.opening_hours]);
|
|
695
|
+
const bookingAdvanceDays = useMemo(() => {
|
|
696
|
+
const oh = salon.opening_hours;
|
|
697
|
+
if (oh && typeof oh === "object") {
|
|
698
|
+
const v = Number(oh.booking_advance_days);
|
|
699
|
+
if (Number.isFinite(v) && v >= 1) return Math.round(v);
|
|
700
|
+
}
|
|
701
|
+
return 60;
|
|
702
|
+
}, [salon.opening_hours]);
|
|
703
|
+
const providerLocale = normalizeLocale(
|
|
704
|
+
localeProp ?? (typeof salon.language === "string" ? salon.language : "bg")
|
|
705
|
+
);
|
|
706
|
+
const resolvedLocale = localeProp ?? (providerLocale === "en" ? "en-US" : "bg-BG");
|
|
707
|
+
const serviceCatalog = useMemo(
|
|
708
|
+
() => enrichServiceCategories(
|
|
709
|
+
parseSalonServices(salon.services).map((s) => localizeService(s, providerLocale))
|
|
710
|
+
),
|
|
711
|
+
[salon.services, providerLocale]
|
|
712
|
+
);
|
|
713
|
+
const categoryTabs = useMemo(
|
|
714
|
+
() => buildServiceCategoryTabs(serviceCatalog),
|
|
715
|
+
[serviceCatalog]
|
|
716
|
+
);
|
|
717
|
+
const bookingServices = useMemo(() => {
|
|
718
|
+
const out = [];
|
|
719
|
+
for (const svc of serviceCatalog) {
|
|
720
|
+
const variants = Array.isArray(svc.variants) ? svc.variants : [];
|
|
721
|
+
if (variants.length === 0) {
|
|
722
|
+
out.push(svc);
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
for (const v of variants) {
|
|
726
|
+
out.push({
|
|
727
|
+
...svc,
|
|
728
|
+
id: `${svc.id}::${v.label}`,
|
|
729
|
+
name: `${svc.name} \u2013 ${v.label}`,
|
|
730
|
+
price: Number(v.price ?? svc.price ?? 0) || 0,
|
|
731
|
+
duration: Math.max(5, Number(v.duration ?? svc.duration ?? 30) || 30),
|
|
732
|
+
variants: void 0
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return out;
|
|
737
|
+
}, [serviceCatalog]);
|
|
738
|
+
const primaryColor = typeof salon.primary_color === "string" && salon.primary_color ? salon.primary_color : "#5B21B6";
|
|
739
|
+
return /* @__PURE__ */ jsx(I18nProvider, { locale: providerLocale, children: /* @__PURE__ */ jsx(
|
|
740
|
+
BookingWidgetInner,
|
|
741
|
+
{
|
|
742
|
+
forwardedRef: ref,
|
|
743
|
+
slug,
|
|
744
|
+
openingHours: resolvedHours,
|
|
745
|
+
bookingBlocks: resolvedBlocks,
|
|
746
|
+
slotIntervalMin,
|
|
747
|
+
bookingAdvanceDays,
|
|
748
|
+
bookingServices,
|
|
749
|
+
serviceCatalog,
|
|
750
|
+
categoryTabs,
|
|
751
|
+
engineUrl,
|
|
752
|
+
primaryColor,
|
|
753
|
+
accentGradient,
|
|
754
|
+
successUrl,
|
|
755
|
+
cancelUrl,
|
|
756
|
+
locale: resolvedLocale,
|
|
757
|
+
formatPrice,
|
|
758
|
+
onEvent,
|
|
759
|
+
salonName: String(salon.name ?? ""),
|
|
760
|
+
basePath
|
|
761
|
+
}
|
|
762
|
+
) });
|
|
763
|
+
}
|
|
764
|
+
);
|
|
765
|
+
var BookingContext = createContext(null);
|
|
766
|
+
function readGlobalString(key) {
|
|
767
|
+
if (typeof window === "undefined") return void 0;
|
|
768
|
+
const v = window[key];
|
|
769
|
+
return typeof v === "string" && v ? v : void 0;
|
|
770
|
+
}
|
|
771
|
+
function readMeta(name) {
|
|
772
|
+
if (typeof document === "undefined") return void 0;
|
|
773
|
+
return document.querySelector(`meta[name="${name}"]`)?.content || void 0;
|
|
774
|
+
}
|
|
775
|
+
function readEnv(key) {
|
|
776
|
+
try {
|
|
777
|
+
const p = typeof process !== "undefined" ? process : void 0;
|
|
778
|
+
const v = p?.env?.[key];
|
|
779
|
+
return typeof v === "string" && v ? v : void 0;
|
|
780
|
+
} catch {
|
|
781
|
+
return void 0;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
function resolveSlug(prop) {
|
|
785
|
+
return prop || readGlobalString("__CLICKA_SALON_SLUG") || readMeta("clicka:salon") || readEnv("NEXT_PUBLIC_SALON_SLUG") || readEnv("NEXT_PUBLIC_CLICKA_SALON");
|
|
786
|
+
}
|
|
787
|
+
function resolveEngine(prop) {
|
|
788
|
+
return prop || readGlobalString("__CLICKA_ENGINE_URL") || readMeta("clicka:engine") || readEnv("NEXT_PUBLIC_ENGINE_URL") || readEnv("NEXT_PUBLIC_CLICKA_ENGINE") || readEnv("NEXT_PUBLIC_CLICKA_API_URL") || // Canonical host (with www). The bare clicka.bg returns a 308 redirect
|
|
789
|
+
// which kills cross-origin fetches because the redirect response itself
|
|
790
|
+
// carries no CORS headers — browsers reject the whole chain.
|
|
791
|
+
"https://www.clicka.bg";
|
|
792
|
+
}
|
|
793
|
+
function resolveLocale(prop) {
|
|
794
|
+
if (prop) return prop;
|
|
795
|
+
if (typeof document !== "undefined") {
|
|
796
|
+
const htmlLang = document.documentElement.lang?.trim();
|
|
797
|
+
if (htmlLang) {
|
|
798
|
+
if (htmlLang === "bg") return "bg-BG";
|
|
799
|
+
if (htmlLang === "en") return "en-US";
|
|
800
|
+
return htmlLang;
|
|
801
|
+
}
|
|
802
|
+
const bodyLang = document.body?.dataset?.lang?.trim();
|
|
803
|
+
if (bodyLang) return bodyLang === "bg" ? "bg-BG" : "en-US";
|
|
804
|
+
}
|
|
805
|
+
if (typeof navigator !== "undefined" && navigator.language) {
|
|
806
|
+
return navigator.language;
|
|
807
|
+
}
|
|
808
|
+
return "bg-BG";
|
|
809
|
+
}
|
|
810
|
+
function defaultReturnUrl(flag) {
|
|
811
|
+
if (typeof window === "undefined") return void 0;
|
|
812
|
+
return `${location.origin}${location.pathname}?${flag}=1`;
|
|
813
|
+
}
|
|
814
|
+
function BookingProvider({
|
|
815
|
+
children,
|
|
816
|
+
salonSlug,
|
|
817
|
+
engineUrl,
|
|
818
|
+
locale,
|
|
819
|
+
successUrl,
|
|
820
|
+
cancelUrl,
|
|
821
|
+
accentGradient,
|
|
822
|
+
formatPrice,
|
|
823
|
+
onEvent,
|
|
824
|
+
basePath,
|
|
825
|
+
autoTriggers = true,
|
|
826
|
+
honorUrlParams = true
|
|
827
|
+
}) {
|
|
828
|
+
const slug = useMemo(() => resolveSlug(salonSlug), [salonSlug]);
|
|
829
|
+
const resolvedEngineUrl = useMemo(() => resolveEngine(engineUrl), [engineUrl]);
|
|
830
|
+
const resolvedLocale = useMemo(() => resolveLocale(locale), [locale]);
|
|
831
|
+
const resolvedSuccessUrl = useMemo(
|
|
832
|
+
() => successUrl ?? defaultReturnUrl("booked"),
|
|
833
|
+
[successUrl]
|
|
834
|
+
);
|
|
835
|
+
const resolvedCancelUrl = useMemo(
|
|
836
|
+
() => cancelUrl ?? defaultReturnUrl("cancelled"),
|
|
837
|
+
[cancelUrl]
|
|
838
|
+
);
|
|
839
|
+
const widgetRef = useRef(null);
|
|
840
|
+
const pendingRef = useRef(void 0);
|
|
841
|
+
const urlAutoOpenedRef = useRef(false);
|
|
842
|
+
const [salon, setSalon] = useState(null);
|
|
843
|
+
const [error, setError] = useState(null);
|
|
844
|
+
useEffect(() => {
|
|
845
|
+
if (!slug) {
|
|
846
|
+
console.error(
|
|
847
|
+
'[@clicka/booking] BookingProvider has no salon slug. Pass `salonSlug` or set NEXT_PUBLIC_SALON_SLUG / <meta name="clicka:salon"> / window.__CLICKA_SALON_SLUG.'
|
|
848
|
+
);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
let cancelled = false;
|
|
852
|
+
const url = `${resolvedEngineUrl.replace(/\/$/, "")}/api/public/v1/salons/${encodeURIComponent(slug)}`;
|
|
853
|
+
fetch(url, { cache: "no-store" }).then((r) => {
|
|
854
|
+
if (!r.ok) throw new Error(`Salon fetch failed: HTTP ${r.status}`);
|
|
855
|
+
return r.json();
|
|
856
|
+
}).then((d) => {
|
|
857
|
+
if (cancelled) return;
|
|
858
|
+
if (!d.salon) throw new Error("Empty salon response");
|
|
859
|
+
setSalon(d.salon);
|
|
860
|
+
setError(null);
|
|
861
|
+
}).catch((e) => {
|
|
862
|
+
if (cancelled) return;
|
|
863
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
864
|
+
console.error("[@clicka/booking] salon fetch failed:", err);
|
|
865
|
+
setError(err);
|
|
866
|
+
});
|
|
867
|
+
return () => {
|
|
868
|
+
cancelled = true;
|
|
869
|
+
};
|
|
870
|
+
}, [resolvedEngineUrl, slug]);
|
|
871
|
+
const open = useCallback(
|
|
872
|
+
(service) => {
|
|
873
|
+
if (widgetRef.current && salon) {
|
|
874
|
+
widgetRef.current.open(service || void 0);
|
|
875
|
+
} else {
|
|
876
|
+
pendingRef.current = service ?? null;
|
|
877
|
+
}
|
|
878
|
+
},
|
|
879
|
+
[salon]
|
|
880
|
+
);
|
|
881
|
+
const close = useCallback(() => widgetRef.current?.close(), []);
|
|
882
|
+
useEffect(() => {
|
|
883
|
+
if (salon && widgetRef.current && pendingRef.current !== void 0) {
|
|
884
|
+
const s = pendingRef.current;
|
|
885
|
+
pendingRef.current = void 0;
|
|
886
|
+
widgetRef.current.open(s || void 0);
|
|
887
|
+
}
|
|
888
|
+
}, [salon]);
|
|
889
|
+
useEffect(() => {
|
|
890
|
+
if (!autoTriggers || typeof document === "undefined") return;
|
|
891
|
+
const SELECTOR = "[data-clicka-book],[data-book-service],[data-book]";
|
|
892
|
+
const handler = (ev) => {
|
|
893
|
+
const t = ev.target;
|
|
894
|
+
if (!t) return;
|
|
895
|
+
const trigger = t.closest(SELECTOR);
|
|
896
|
+
if (!trigger) return;
|
|
897
|
+
ev.preventDefault();
|
|
898
|
+
const raw = trigger.getAttribute("data-clicka-book") ?? trigger.getAttribute("data-book-service") ?? trigger.getAttribute("data-book") ?? "";
|
|
899
|
+
const service = raw && raw !== "true" ? raw : void 0;
|
|
900
|
+
open(service);
|
|
901
|
+
};
|
|
902
|
+
document.addEventListener("click", handler);
|
|
903
|
+
return () => document.removeEventListener("click", handler);
|
|
904
|
+
}, [autoTriggers, open]);
|
|
905
|
+
useEffect(() => {
|
|
906
|
+
if (!honorUrlParams || !salon || urlAutoOpenedRef.current) return;
|
|
907
|
+
if (typeof location === "undefined") return;
|
|
908
|
+
const params = new URLSearchParams(location.search);
|
|
909
|
+
const initial = params.get("service");
|
|
910
|
+
if (initial || params.has("book")) {
|
|
911
|
+
urlAutoOpenedRef.current = true;
|
|
912
|
+
open(initial || void 0);
|
|
913
|
+
}
|
|
914
|
+
}, [honorUrlParams, salon, open]);
|
|
915
|
+
const value = useMemo(
|
|
916
|
+
() => ({ open, close, isReady: !!salon, error, salon }),
|
|
917
|
+
[open, close, salon, error]
|
|
918
|
+
);
|
|
919
|
+
const modalNode = salon && slug ? /* @__PURE__ */ jsx(
|
|
920
|
+
BookingWidget,
|
|
921
|
+
{
|
|
922
|
+
ref: widgetRef,
|
|
923
|
+
slug,
|
|
924
|
+
salon,
|
|
925
|
+
engineUrl: resolvedEngineUrl,
|
|
926
|
+
locale: resolvedLocale,
|
|
927
|
+
successUrl: resolvedSuccessUrl,
|
|
928
|
+
cancelUrl: resolvedCancelUrl,
|
|
929
|
+
accentGradient,
|
|
930
|
+
formatPrice,
|
|
931
|
+
onEvent,
|
|
932
|
+
basePath
|
|
933
|
+
}
|
|
934
|
+
) : null;
|
|
935
|
+
return /* @__PURE__ */ jsxs(BookingContext.Provider, { value, children: [
|
|
936
|
+
children,
|
|
937
|
+
modalNode && typeof document !== "undefined" ? createPortal(modalNode, document.body) : modalNode
|
|
938
|
+
] });
|
|
939
|
+
}
|
|
940
|
+
function useBooking() {
|
|
941
|
+
const ctx = useContext(BookingContext);
|
|
942
|
+
if (!ctx) {
|
|
943
|
+
throw new Error(
|
|
944
|
+
"[@clicka/booking] useBooking() must be called inside <BookingProvider>."
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
return ctx;
|
|
948
|
+
}
|
|
949
|
+
var BookingButton = forwardRef(
|
|
950
|
+
function BookingButton2({ service, onClick, type = "button", children, ...rest }, ref) {
|
|
951
|
+
const { open } = useBooking();
|
|
952
|
+
return /* @__PURE__ */ jsx(
|
|
953
|
+
"button",
|
|
954
|
+
{
|
|
955
|
+
ref,
|
|
956
|
+
type,
|
|
957
|
+
onClick: (e) => {
|
|
958
|
+
onClick?.(e);
|
|
959
|
+
if (!e.defaultPrevented) open(service);
|
|
960
|
+
},
|
|
961
|
+
...rest,
|
|
962
|
+
children
|
|
963
|
+
}
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
);
|
|
967
|
+
|
|
968
|
+
export { BookingButton, BookingContext, BookingProvider, BookingWidget, useBooking };
|
|
969
|
+
//# sourceMappingURL=index.js.map
|
|
970
|
+
//# sourceMappingURL=index.js.map
|