UNPKG

@wallfar/ocd-studio-core-sdk

Version:

Helper SDK for our OneClick Studio modules

1,404 lines (1,395 loc) 75.3 kB
import { reactive, ref, watch, toRefs, computed, unref, toValue, onMounted, onBeforeUnmount, shallowRef, getCurrentScope, onScopeDispose, readonly, inject, provide } from 'vue'; import { useHead, useAsyncData, useRequestURL } from 'nuxt/app'; import { getDoc, doc, getDocs, query, collection, where, limit, orderBy, startAfter, documentId, Timestamp } from 'firebase/firestore'; import { v as validateAddress } from './shared/ocd-studio-core-sdk.ChPIrm-j.mjs'; import { getLocalTimeZone, toCalendarDate, today, CalendarDate } from '@internationalized/date'; import { r as roundMoney, c as clampMoney } from './shared/ocd-studio-core-sdk.D3EQiCxo.mjs'; async function fetchProductFirebase(config, slug) { const { db, products: products_collection } = config; const q = query( collection(db, products_collection), where("slug", "==", slug), limit(1) ); const snapshot = await getDocs(q); if (snapshot.empty) return null; const doc2 = snapshot.docs[0]; return { id: doc2.id, ...doc2.data() }; } async function fetchProductsFirebase(config, collectionId, sort, lastFetchedItemId, itemsPerPage) { const { db, products: products_collection } = config; let q = query(collection(db, products_collection), where("status", "==", "published"), limit(itemsPerPage || 12)); if (collectionId) { q = query(q, where("collections", "array-contains", collectionId)); } if (sort) { switch (sort) { case "created-descending": q = query(q, orderBy("createdAt", "desc")); break; case "created-ascending": q = query(q, orderBy("createdAt", "asc")); break; case "price-descending": q = query(q, orderBy("price", "desc")); break; case "price-ascending": q = query(q, orderBy("price", "asc")); break; case "title-descending": q = query(q, orderBy("title", "desc")); break; case "title-ascending": q = query(q, orderBy("title", "asc")); break; } if (lastFetchedItemId) { q = query(q, startAfter(lastFetchedItemId)); } } const snapshot = await getDocs(q); if (snapshot.empty) return null; const docs = snapshot.docs; return docs.map((doc2) => ({ id: doc2.id, ...doc2.data() })); } async function fetchCollectionsFirebase(config) { const { db, collections: collections_collection } = config; if (!collections_collection) return null; if (collections_collection.includes(":")) { const collectionParts = collections_collection.split(":"); const docSnap = await getDoc(doc(db, collectionParts[0])); const collections = docSnap.exists() ? docSnap.data()?.[collectionParts[1]] : null; return collections?.filter((collection2) => collection2.status === "published") || null; } else { const snapshot = await getDocs(query(collection(db, collections_collection))); return !snapshot.empty ? snapshot.docs.map((doc2) => ({ id: doc2.id, ...doc2.data() })).filter((collection2) => collection2.status === "published") : null; } } async function fetchShippingOptionsFirebase(config) { const { db, shippingOptions: shipping_options_collection } = config; if (!shipping_options_collection) return null; if (shipping_options_collection.includes(":")) { const collectionParts = shipping_options_collection.split(":"); const docSnap = await getDoc(doc(db, collectionParts[0])); const options = docSnap.exists() ? docSnap.data()?.[collectionParts[1]] : null; return options?.filter((opt) => opt.status === "published") || null; } else { const snapshot = await getDocs(query(collection(db, shipping_options_collection))); return !snapshot.empty ? snapshot.docs.map((doc2) => ({ id: doc2.id, ...doc2.data() })).filter((opt) => opt.status === "published") : null; } } function createProductConfigurator(product) { const selection = reactive( product.options?.reduce((acc, o) => { acc[o.name] = o.values[0]?.value || ""; return acc; }, {}) ); function getSelectedVariant() { if (product.variants?.length === 0) return _getMainProductAsVariant(); return getVariant(selection); } function getVariant(variant) { if (product.variants?.length === 0) return _getMainProductAsVariant(); return product.variants?.find( (v) => v.options.every((o) => o.value === variant[o.option]) ); } function _getMainProductAsVariant() { return { options: [], combinedOptions: "", stock: product.stock, price: product.price, image: product.media?.[0], disabled: false, name: product.title }; } return { selection, getSelectedVariant, getVariant }; } class Webshop { config; cart = ref([]); taxRate = ref(0); _shippingOptions = ref(null); _shippingOption = ref(null); _activePromoCode = ref(null); _collections = ref(null); _deliveryAddress = ref({ firstName: "", lastName: "", company: "", streetLine1: "", streetLine2: "", streetLine3: "", city: "", stateOrProvince: "", countyOrDistrict: "", postalCode: "", country: "", phoneNumber: "", email: "", instructions: "", latitude: 0, longitude: 0 }); constructor(config) { this.config = config; if (!this.config.defaultBrand) this.config.defaultBrand = ""; if (!this.config.defaultCurrency) this.config.defaultCurrency = "USD"; if (!this.config.defaultLocale) this.config.defaultLocale = "en-US"; this.cart.value = localStorage.getItem("webshop_cart") ? JSON.parse(localStorage.getItem("webshop_cart") || "[]") : []; this._activePromoCode.value = localStorage.getItem("webshop_promoCode") ? JSON.parse(localStorage.getItem("webshop_promoCode") || "null") : null; this._shippingOption.value = localStorage.getItem("webshop_shippingOption") ? JSON.parse(localStorage.getItem("webshop_shippingOption") || "null") : null; const saved = localStorage.getItem("webshop_deliveryAddress"); if (saved) { try { const data = JSON.parse(saved); Object.assign(this._deliveryAddress.value, data); } catch (e) { console.error("Could not parse saved address:", e); } } watch(this.cart, () => { localStorage.setItem("webshop_cart", JSON.stringify(this.cart.value)); }, { deep: true }); watch(this._activePromoCode, () => { localStorage.setItem("webshop_promoCode", JSON.stringify(this._activePromoCode.value)); }, { deep: true }); watch(this._deliveryAddress, () => { localStorage.setItem("webshop_deliveryAddress", JSON.stringify(this._deliveryAddress.value)); }, { deep: true }); watch(this._shippingOption, () => { localStorage.setItem("webshop_shippingOption", JSON.stringify(this._shippingOption.value)); }, { deep: true }); this.refreshCollections(); this.refreshShippingOptions(); } async refreshCollections() { if (this.config.provider === "firebase") { this._collections.value = await fetchCollectionsFirebase(this.config.firebase); return this._collections.value; } throw new Error(`Provider ${this.config.provider} not supported.`); } async refreshShippingOptions() { if (this.config.provider === "firebase") { this._shippingOptions.value = await fetchShippingOptionsFirebase(this.config.firebase); return this._shippingOptions.value; } throw new Error(`Provider ${this.config.provider} not supported.`); } async fetchProduct(slug) { if (this.config.provider === "firebase") { return fetchProductFirebase(this.config.firebase, slug); } throw new Error(`Provider ${this.config.provider} not supported.`); } /** * Sends a notification to a user. * * @param product: Product created in the Products module from whitelabel core modules package. */ setProductSeo(product) { if (!product) { throw new Error("Product is required"); } useHead({ title: product.title, meta: [ { name: "description", content: product.description }, { property: "og:title", content: product.title }, { property: "og:description", content: product.description }, { property: "og:image", content: product.media[0] }, { property: "og:url", content: `https://yourdomain.com/products/${product.slug}` }, { property: "og:type", content: "product" }, { name: "twitter:card", content: "summary_large_image" }, { name: "twitter:title", content: product.title }, { name: "twitter:description", content: product.description }, { name: "twitter:image", content: product.media[0] } ], script: [ { type: "application/ld+json", innerHTML: JSON.stringify({ "@context": "https://schema.org/", "@type": "Product", name: product.title, image: product.media[0], description: product.description, sku: product.sku, mpn: product.mpn, gtin13: product.gtin, brand: { "@type": "Brand", name: product.brand || this.config.defaultBrand }, offers: { "@type": "Offer", url: `https://yourdomain.com/products/${product.slug}`, priceCurrency: product.currency || "EUR", price: product.price, availability: product.stock > 0 ? "https://schema.org/InStock" : "https://schema.org/OutOfStock", itemCondition: "https://schema.org/NewCondition" } }) } ] }); } createConfigurator(product) { return createProductConfigurator(product); } async addToCart(product, variant, quantity = 1) { const requiredKeys = ["id", "title", "slug", "price", "currency", "media"]; const missing = requiredKeys.filter((key) => !product[key]); if (missing.length) { throw new Error(`Product is missing required properties: ${missing.join(", ")}`); } const index = this.cart.value.findIndex( (i) => i.productId === product.id && deepEqual(i.variant, variant) ); const configurator = this.createConfigurator(product); const selectedVariant = configurator.getVariant(variant || {}); console.log("index", index); if (index !== -1) { this.cart.value[index].quantity += quantity; } else { let newProd = JSON.parse(JSON.stringify({ productId: product.id, title: product.title, slug: product.slug, thumbnail: selectedVariant?.image || product.media[0], price: selectedVariant?.price || product.price, currency: product.currency, stock: selectedVariant?.stock || product.stock, quantity, variant: JSON.parse(JSON.stringify(variant)) })); this.cart.value.push(newProd); } } removeFromCart(productId, variant) { this.cart.value = this.cart.value.filter( (i) => !(i.productId === productId && JSON.stringify(i.variant) === JSON.stringify(variant)) ); } decreaseItem(productId, variant) { const idx = this.cart.value.findIndex( (i) => i.productId === productId && JSON.stringify(i.variant) === JSON.stringify(variant) ); if (idx !== -1) { if (this.cart.value[idx].quantity > 1) { this.cart.value[idx].quantity -= 1; } else { this.cart.value.splice(idx, 1); } } } updateQuantity(productId, variant, newQty) { const idx = this.cart.value.findIndex( (i) => i.productId === productId && JSON.stringify(i.variant) === JSON.stringify(variant) ); if (idx !== -1) { if (newQty <= 0) { this.cart.value.splice(idx, 1); } else { this.cart.value[idx].quantity = newQty; } } } async validatePromoCode(code) { try { if (this.config.promoCodeValidationRequest) { return await this.config.promoCodeValidationRequest(code); } } catch (err) { return { valid: false, error: "Code is invalid" }; } } updateDeliveryAddress(updates) { Object.assign(this._deliveryAddress.value, updates); } // 💡 Accessors get cartItems() { return this.cart.value; } get activePromoCode() { return this._activePromoCode.value; } get collections() { return this._collections; } get itemCount() { return this.cart.value.reduce((sum, item) => sum + item.quantity, 0); } get subtotal() { return this.cart.value.reduce((sum, item) => sum + item.price * item.quantity, 0); } get deliveryAddress() { return toRefs(this._deliveryAddress.value); } get shippingOptions() { if (!this._shippingOptions?.value || this._shippingOptions.value.length === 0) return []; return this._shippingOptions.value.reduce((options, option) => { if (!option) return options; if (option.status !== "published") return options; const validCountry = !option.countries || option.countries.length === 0 || option.countries.includes(this.country); if (!validCountry) return options; let validCondition = option.condition === "always"; if (!validCondition) { if (option.condition === "price_based") { const MIN = option.condition_min || 0; const MAX = option.condition_max || Infinity; validCondition = this.subtotal >= MIN && this.subtotal <= MAX; } else if (option.condition === "weight_based") ; } if (validCountry && validCondition) { options.push(option); } return options; }, []); } get shippingOption() { return this._shippingOption.value; } set shippingOption(option) { this._shippingOption.value = option; } get shippingFee() { if (this._activePromoCode.value?.type === "free_shipping") { return 0; } if (this._shippingOption.value) { return this._shippingOptions.value?.find((opt) => opt.id === this._shippingOption.value)?.price || 0; } return 0; } get discount() { if (!this._activePromoCode.value) return 0; if (this._activePromoCode.value.type === "percentage") { return this.subtotal * ((this._activePromoCode.value.value || 0) / 100) || 0; } if (this._activePromoCode.value.type === "fixed") { return this._activePromoCode.value.value || 0; } return 0; } get tax() { return this.taxRate.value * (this.subtotal - this.discount); } get total() { const total = this.subtotal + this.tax + this.shippingFee - this.discount; return total > 0 ? total : 0; } get country() { return this._deliveryAddress.value.country; } set country(val) { this._deliveryAddress.value.country = val; } // 💳 Controls async applyPromoCode(code) { code = code.trim().toUpperCase(); try { if (this.config.provider === "firebase") { const result = await this.validatePromoCode(code); if (!result || !result.valid || !result.promo) throw new Error("Code is invalid"); this._activePromoCode.value = result.promo; return result; } else { throw new Error(`Provider ${this.config.provider} not supported.`); } } catch (error) { throw error; } } async createPaymentLink() { try { if (!this.cart.value || this.cart.value.length === 0) throw new Error("No products in cart"); if (!this._shippingOption.value) throw new Error("No shipping option selected"); if (validateAddress(this._deliveryAddress.value).length > 0) throw new Error("Invalid address"); if (this.config.createPaymentLinkRequest) { let checkoutData = { cart: this.cart.value?.map((item) => ({ productId: item.productId, variant: item.variant, quantity: item.quantity })), shippingOption: this._shippingOption.value, activePromoCode: this._activePromoCode.value ? this._activePromoCode.value.code : null, deliveryAddress: this._deliveryAddress.value }; return await this.config.createPaymentLinkRequest(checkoutData); } } catch (err) { const inner = err.data?.message ?? (typeof err.data === "string" ? err.data : null) ?? err.message; throw new Error(inner); } } removePromoCode() { this._activePromoCode.value = null; } clearCart() { this.cart.value = []; this._activePromoCode.value = null; } // helpers formatPrice(price, currency, locale) { try { return new Intl.NumberFormat(locale || this.config.defaultLocale, { style: "currency", currency: currency || this.config.defaultCurrency }).format(price); } catch { return `${currency} ${price.toFixed(2)}`; } } // useProduct useProduct(slug) { const slugKey = computed(() => unref(slug)); const key = computed(() => `product-${slugKey.value}`); const selection = ref({}); const getSelectedVariant = ref(() => void 0); const { data, pending, error, refresh } = useAsyncData( key, async () => { const s = typeof slugKey.value === "string" ? slugKey.value : slugKey.value(); const prod = await fetchProductFirebase(this.config.firebase, s); if (!prod) { throw new Error(`Product "${s}" not found`); } const cfg = createProductConfigurator(prod); selection.value = cfg.selection; getSelectedVariant.value = cfg.getSelectedVariant; return prod; } ); const product = computed(() => data.value || null); const selectedVariant = computed(() => getSelectedVariant.value()); return { product, pending, error, refresh, selection, selectedVariant }; } useCollection(slug, subcollectionSlug) { const getSlug = (value) => { if (!value) return void 0; const slugValue = unref(value); return typeof slugValue === "function" ? slugValue() : slugValue; }; const slugKey = computed(() => { const value = getSlug(slug); return Array.isArray(value) ? value[0] : value; }); const subcollectionSlugKey = computed(() => { const value = getSlug(subcollectionSlug); if (value) return Array.isArray(value) ? value[0] : value; const routeSlugs = getSlug(slug); return Array.isArray(routeSlugs) ? routeSlugs[1] : void 0; }); const sort = ref("created-descending"); const _products = ref([]); const lastFetchedItemId = ref(""); const collection = computed(() => { const mainCollection = this._collections.value?.find((c) => c.slug === slugKey.value); if (!subcollectionSlugKey.value) return mainCollection; return mainCollection?.subcollections?.find((c) => c.slug === subcollectionSlugKey.value); }); const products = computed(() => _products.value); const pending = ref(false); const error = ref(null); const refresh = () => this.refreshCollections(); watch([slugKey, subcollectionSlugKey, collection], () => { reset(); loadProducts(); }); watch(sort, () => { reset(); loadProducts(); }); const reset = () => { _products.value = []; lastFetchedItemId.value = ""; pending.value = false; error.value = null; }; const loadProducts = async () => { pending.value = true; error.value = null; try { const prods = await fetchProductsFirebase(this.config.firebase, collection.value?.id, sort.value, lastFetchedItemId.value, this.config.itemsPerPage); _products.value = prods || []; } catch (err) { error.value = err; } finally { pending.value = false; } }; loadProducts(); return { collection, products, pending, error, refresh, sort }; } // useLocalized(product.title, 'nl') // returns Dutch title or fallback // useInventoryStatus(product) // useCheckout() // Stores cart items, Calculates total price, Prepares payload for order API // useProductFilters() // Maps query params (?color=red&size=m) into filters // useRelatedProducts() // Fetches related products based on tags, collections, manual } function deepEqual(a, b) { function isObject(o) { return o !== null && typeof o === "object"; } if (!isObject(a) || !isObject(b)) { return a === b; } const aKeys = Object.keys(a); const bKeys = Object.keys(b); if (aKeys.length !== bKeys.length) return false; return aKeys.every( (key) => b.hasOwnProperty(key) && deepEqual(a[key], b[key]) ); } function sanitizeTimestamps(obj) { if (obj == null) return obj; if (obj instanceof Timestamp) return obj.toDate().toISOString(); if (obj instanceof Date) return obj.toISOString(); if (Array.isArray(obj)) return obj.map(sanitizeTimestamps); if (typeof obj === "object" && obj.constructor === Object) { const out = {}; for (const key of Object.keys(obj)) { out[key] = sanitizeTimestamps(obj[key]); } return out; } return obj; } function getPublishedVersion(entry, locale) { const references = Array.isArray(entry.liveByLocale) ? entry.liveByLocale : []; const reference = references.find((item) => item.locale === locale); const versionId = reference?.versionId || reference?.id; if (!reference || !versionId) return null; return { versionId, slug: reference.slug || "" }; } function buildI18nParams(entry) { const i18nParams = {}; const references = Array.isArray(entry.liveByLocale) ? entry.liveByLocale : []; references.forEach((reference) => { if (reference.locale && reference.slug) { i18nParams[reference.locale] = { slug: reference.slug }; } }); return i18nParams; } function parseContent(value, versionId) { if (typeof value !== "string") { return value && typeof value === "object" ? value : {}; } try { return JSON.parse(value || "{}"); } catch { throw new Error(`Content version "${versionId}" contains invalid JSON`); } } function toIsoString(value) { if (!value) return null; if (value instanceof Timestamp) return value.toDate().toISOString(); if (value instanceof Date) return value.toISOString(); if (typeof value.toDate === "function") { return value.toDate().toISOString(); } const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date.toISOString(); } function mapContentVersion(entryId, entry, versionSnapshot) { const version = versionSnapshot.data(); const content = { ...version, id: versionSnapshot.id, entryId, versionId: versionSnapshot.id, collectionId: entry.collectionId ?? version.collectionId, content: parseContent(version.content, versionSnapshot.id), createdAt: toIsoString(version.created?.at ?? version.createdAt), updatedAt: toIsoString( version.updated?.at ?? version.updatedAt ?? entry.updated?.at ?? entry.updatedAt ), i18nParams: buildI18nParams(entry) }; delete content.created; delete content.updated; return sanitizeTimestamps(content); } function createCursor(entrySnapshot) { const createdAt = entrySnapshot.data()?.created?.at; let timestamp = null; if (createdAt instanceof Timestamp) { timestamp = createdAt; } else if (typeof createdAt?.seconds === "number" && typeof createdAt?.nanoseconds === "number") { timestamp = new Timestamp(createdAt.seconds, createdAt.nanoseconds); } else if (createdAt instanceof Date) { timestamp = Timestamp.fromDate(createdAt); } if (!timestamp) return null; return { createdAtSeconds: timestamp.seconds, createdAtNanoseconds: timestamp.nanoseconds, entryId: entrySnapshot.id }; } async function fetchPublishedVersion(config, entrySnapshot, locale) { const entry = entrySnapshot.data(); const publishedVersion = getPublishedVersion(entry, locale); if (!publishedVersion) return null; const versionSnapshot = await getDoc(doc( config.db, config.entries, entrySnapshot.id, "versions", publishedVersion.versionId )); if (!versionSnapshot.exists()) return null; return mapContentVersion(entrySnapshot.id, entry, versionSnapshot); } async function fetchContentFirebase(config, settings) { try { const { db, entries: entries_collection } = config; const { collection: collectionId, slug, entryId, version, locale } = settings; if (!db || !entries_collection || !collectionId || !locale) { console.warn("Missing Firebase configuration or settings"); return null; } let entrySnapshot; if (version === "latest") { const q = query( collection(db, entries_collection), where("collectionId", "==", collectionId), where("liveKeys", "array-contains", slug + "_" + locale), limit(1) ); const snapshot = await getDocs(q); if (snapshot.empty) return null; entrySnapshot = snapshot.docs[0]; } else { if (!entryId || !version) return null; entrySnapshot = await getDoc(doc(db, entries_collection, entryId)); if (!entrySnapshot.exists() || entrySnapshot.data()?.collectionId !== collectionId) { return null; } } if (!entrySnapshot.exists()) return null; if (version === "latest") { return fetchPublishedVersion(config, entrySnapshot, locale); } const versionSnapshot = await getDoc(doc( db, entries_collection, entrySnapshot.id, "versions", version )); if (!versionSnapshot.exists()) return null; return mapContentVersion( entrySnapshot.id, entrySnapshot.data(), versionSnapshot ); } catch (error) { console.error("Error fetching content from Firebase", error); throw error; } } async function fetchContentCollectionFirebase(config, settings, cursor = null) { const { db, entries: entries_collection } = config; const { collection: collectionId, locale, pageSize } = settings; if (!db || !entries_collection || !collectionId || !locale) { throw new Error("Missing Firebase configuration or collection settings"); } const entries = []; let scanCursor = cursor; let nextCursor = null; let hasMore = false; let exhausted = false; const scanSize = pageSize + 1; while (entries.length <= pageSize && !exhausted) { const constraints = [ where("collectionId", "==", collectionId), orderBy("created.at", "desc"), orderBy(documentId(), "desc") ]; if (scanCursor) { constraints.push(startAfter( new Timestamp( scanCursor.createdAtSeconds, scanCursor.createdAtNanoseconds ), scanCursor.entryId )); } constraints.push(limit(scanSize)); const snapshot = await getDocs(query( collection(db, entries_collection), ...constraints )); if (snapshot.empty) break; const resolvedEntries = await Promise.all( snapshot.docs.map((entry) => fetchPublishedVersion(config, entry, locale)) ); for (let index = 0; index < snapshot.docs.length; index += 1) { const entryCursor = createCursor(snapshot.docs[index]); if (!entryCursor) { throw new Error( `Content entry "${snapshot.docs[index].id}" has no valid created.at timestamp` ); } scanCursor = entryCursor; const resolvedEntry = resolvedEntries[index]; if (!resolvedEntry) continue; if (entries.length < pageSize) { entries.push(resolvedEntry); nextCursor = entryCursor; } else { hasMore = true; break; } } if (hasMore) break; exhausted = snapshot.size < scanSize; } return { entries, nextCursor: hasMore ? nextCursor : null }; } function keyPart(value) { return encodeURIComponent(String(value ?? "")); } function contentSourceKey(config) { const db = config.firebase?.db; return [ config.provider, db?.app?.name || "", db?.app?.options?.projectId || "", db?._databaseId?.database || "", config.firebase?.entries || "" ].map(keyPart).join(":"); } function normalizeError(error) { if (!error) return null; if (error instanceof Error) return error; return new Error( typeof error === "string" ? error : "An unknown content delivery error occurred" ); } function cursorsEqual(first, second) { if (!first || !second) return first === second; return first.entryId === second.entryId && first.createdAtSeconds === second.createdAtSeconds && first.createdAtNanoseconds === second.createdAtNanoseconds; } class ContentDelivery { config; constructor(config) { this.config = config; } useContent(settings) { const url = useRequestURL(); const collectionKey = computed(() => toValue(settings.collection) || ""); const slugKey = computed(() => toValue(settings.slug) || ""); const localeKey = computed(() => (settings.locale ? toValue(settings.locale) : void 0) || this.config.defaultLocale || "en"); const versionKey = computed(() => url.searchParams.get("content-version") || (settings.version ? toValue(settings.version) : void 0) || "latest"); const entryIdKey = computed(() => url.searchParams.get("content-entry-id") || (settings.entryId ? toValue(settings.entryId) : void 0) || ""); const sourceKey = contentSourceKey(this.config); const key = computed(() => [ "content", sourceKey, collectionKey.value, slugKey.value, localeKey.value, versionKey.value, entryIdKey.value ].map(keyPart).join(":")); const showLivePreview = ref(false); const previewLayout = ref(null); const previewSettings = ref({}); const previewEntryContent = ref({}); const asyncData = useAsyncData( () => key.value, async () => { const requestFingerprint = key.value; const resolvedSettings = { collection: collectionKey.value, slug: slugKey.value, locale: localeKey.value, version: versionKey.value, entryId: entryIdKey.value || void 0 }; const content2 = await fetchContentFirebase( this.config.firebase, resolvedSettings ); if (!content2) { throw new Error(`Content "${resolvedSettings.slug}" not found`); } return { fingerprint: requestFingerprint, entry: content2 }; } ); const { data, status, pending, error, refresh } = asyncData; const deliveredContent = computed(() => data.value?.fingerprint === key.value ? data.value.entry : void 0); const content = computed(() => deliveredContent.value?.content || []); const entrySettings = computed(() => deliveredContent.value?.settings || {}); const entryContent = computed(() => deliveredContent.value?.structuredContent || {}); const i18nParams = computed(() => deliveredContent.value?.i18nParams || {}); const seo = computed(() => ({ title: deliveredContent.value?.title || "", description: deliveredContent.value?.description || "", slug: deliveredContent.value?.slug || "" })); const normalizedAsyncError = computed(() => normalizeError(error.value)); const visibleContent = computed(() => { return showLivePreview.value ? previewLayout.value : content.value; }); const visibleSettings = computed(() => { return showLivePreview.value ? previewSettings.value : entrySettings.value; }); const visibleEntryContent = computed(() => { return showLivePreview.value ? previewEntryContent.value : entryContent.value; }); const onLivePreviewMessage = (ev) => { const data2 = ev.data; if (!data2 || typeof data2 !== "object" || url.searchParams.get("live-editor") !== "true") return; if (data2.type === "layout:update") { const payload = data2.payload; previewLayout.value = payload?.layout ? JSON.parse(payload?.layout) : previewLayout.value; } else if (data2.type === "settings:update") { const payload = data2.payload; previewSettings.value = payload?.settings ? JSON.parse(payload?.settings) : previewSettings.value; } else if (data2.type === "structuredContent:update") { const payload = data2.payload; previewEntryContent.value = payload?.entry ? JSON.parse(payload?.entry) : previewEntryContent.value; } }; onMounted(() => { showLivePreview.value = url.searchParams.get("live-editor") === "true"; if (showLivePreview.value) { window.parent.postMessage({ type: "page:loaded" }, "*"); window.addEventListener("message", onLivePreviewMessage); } }); onBeforeUnmount(() => { if (showLivePreview.value) { window.removeEventListener("message", onLivePreviewMessage); } }); const result = { content: visibleContent, settings: visibleSettings, entry: visibleEntryContent, seo, i18nParams, status, pending, error: normalizedAsyncError, refresh }; const contentRequestIsSettled = () => data.value?.fingerprint === key.value && status.value === "success" || status.value === "error"; const waitForCurrentContent = () => { if (contentRequestIsSettled()) return Promise.resolve(); return new Promise((resolve) => { let stop = () => { }; stop = watch( [ key, () => data.value?.fingerprint, status ], () => { if (!contentRequestIsSettled()) return; stop(); resolve(); }, { immediate: true, flush: "sync" } ); }); }; return { ...result, then(onFulfilled, onRejected) { return waitForCurrentContent().then(() => result).then(onFulfilled, onRejected); } }; } /** Fetch and incrementally paginate published entries from a content collection. */ useCollection(settings) { const collectionKey = computed(() => toValue(settings.collection) || ""); const localeKey = computed(() => (settings.locale ? toValue(settings.locale) : void 0) || this.config.defaultLocale || "en"); const pageSizeKey = computed(() => { const requestedPageSize = settings.pageSize === void 0 ? void 0 : toValue(settings.pageSize); const pageSize = Math.floor(Number( requestedPageSize ?? this.config.defaultQueryLimit ?? 20 )); return Number.isFinite(pageSize) ? Math.max(1, pageSize) : 20; }); const sourceKey = contentSourceKey(this.config); const fingerprint = computed(() => [ "content-collection", sourceKey, collectionKey.value, localeKey.value, pageSizeKey.value ].map(keyPart).join(":")); const resolvedSettings = computed(() => ({ collection: collectionKey.value, locale: localeKey.value, pageSize: pageSizeKey.value })); const asyncData = useAsyncData( () => fingerprint.value, async () => { const requestFingerprint = fingerprint.value; const requestSettings = { ...resolvedSettings.value }; if (!requestSettings.collection) { throw new Error("A content collection is required"); } const page = await fetchContentCollectionFirebase( this.config.firebase, requestSettings ); return { ...page, fingerprint: requestFingerprint }; }, { deep: false, dedupe: "cancel" } ); const { data, status, pending, error: initialError, refresh: refreshInitial } = asyncData; const storedEntries = shallowRef([]); const nextCursor = shallowRef(null); const appliedFingerprint = ref(null); const loadingMore = ref(false); const paginationError = ref(null); let generation = 0; let activeLoadMore = null; const invalidatePagination = () => { generation += 1; activeLoadMore = null; loadingMore.value = false; }; const applyFirstPage = (page) => { if (!page || page.fingerprint !== fingerprint.value) return; invalidatePagination(); storedEntries.value = [...page.entries]; nextCursor.value = page.nextCursor; appliedFingerprint.value = page.fingerprint; paginationError.value = null; }; watch( fingerprint, () => { invalidatePagination(); storedEntries.value = []; nextCursor.value = null; appliedFingerprint.value = null; paginationError.value = null; applyFirstPage(data.value); }, { flush: "sync" } ); watch( () => data.value, (page) => applyFirstPage(page), { immediate: true, flush: "sync" } ); const entries = computed(() => storedEntries.value); const hasMore = computed(() => appliedFingerprint.value === fingerprint.value && nextCursor.value !== null); const error = computed(() => normalizeError(initialError.value) || paginationError.value); const mergeEntries = (newEntries) => { const entryIds = new Set( storedEntries.value.map((entry) => entry.entryId || entry.id) ); storedEntries.value = [ ...storedEntries.value, ...newEntries.filter((entry) => { const entryId = entry.entryId || entry.id; if (entryIds.has(entryId)) return false; entryIds.add(entryId); return true; }) ]; }; const loadMore = async () => { if (import.meta.server) return; if (activeLoadMore) return activeLoadMore; if (pending.value || !hasMore.value || !nextCursor.value) return; const requestFingerprint = fingerprint.value; const requestGeneration = generation; const requestCursor = nextCursor.value; const requestSettings = { ...resolvedSettings.value }; let task = Promise.resolve(); task = (async () => { loadingMore.value = true; paginationError.value = null; try { const page = await fetchContentCollectionFirebase( this.config.firebase, requestSettings, requestCursor ); if (requestGeneration !== generation || requestFingerprint !== fingerprint.value || !cursorsEqual(requestCursor, nextCursor.value)) { return; } if (page.nextCursor && cursorsEqual(page.nextCursor, requestCursor)) { throw new Error("Content pagination cursor did not advance"); } mergeEntries(page.entries); nextCursor.value = page.nextCursor; } catch (cause) { if (requestGeneration === generation && requestFingerprint === fingerprint.value) { paginationError.value = normalizeError(cause); } } finally { if (activeLoadMore === task) { activeLoadMore = null; loadingMore.value = false; } } })(); activeLoadMore = task; return task; }; if (getCurrentScope()) { onScopeDispose(invalidatePagination); } const collectionRequestIsSettled = () => data.value?.fingerprint === fingerprint.value && status.value === "success" || status.value === "error"; const waitForCurrentCollection = () => { if (collectionRequestIsSettled()) return Promise.resolve(); return new Promise((resolve) => { let stop = () => { }; stop = watch( [ fingerprint, () => data.value?.fingerprint, status ], () => { if (!collectionRequestIsSettled()) return; stop(); resolve(); }, { immediate: true, flush: "sync" } ); }); }; const refresh = async () => { invalidatePagination(); storedEntries.value = []; nextCursor.value = null; appliedFingerprint.value = null; paginationError.value = null; await refreshInitial({ dedupe: "cancel" }); if (!initialError.value) { applyFirstPage(data.value); } }; const result = { entries, pending, loadingMore: readonly(loadingMore), hasMore, status, error, loadMore, refresh }; return { ...result, then(onFulfilled, onRejected) { return waitForCurrentCollection().then(() => result).then(onFulfilled, onRejected); } }; } } async function fetchAgendaFirebase(config, id) { const { db, agendas: agendas_collection } = config; const res = await getDoc(doc(db, agendas_collection, id)); if (!res.exists()) return null; return { ...res.data() }; } async function fetchReservedSpotsFirebase(config, agendaId, dateString) { const { db, reserved_spots: reserved_spots_collection } = config; const res = await getDoc(doc(db, reserved_spots_collection, `${agendaId}_${dateString}`)); if (!res.exists()) return []; const docData = res.data(); delete docData?.lastUpdated; const result = []; Object.keys(docData || {}).forEach((key) => { Object.entries(docData?.[key] || {}).forEach(([timeRange, count]) => { const [startTime, endTime] = timeRange.split("-"); result.push({ resourceId: key, startTime, endTime, reserved: Number(count) }); }); }); return result || []; } function generateReservationId() { return `res_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; } function parseTimeToNumber(time) { const [hours, minutes] = time.split(":").map(Number); return hours + minutes / 60; } function sortTimeslots(timeslots) { return [...timeslots].sort( (a, b) => parseTimeToNumber(a.startTime) - parseTimeToNumber(b.startTime) ); } function isSameTimeslot(a, b) { if (!a || !b) return false; return a.startTime === b.startTime && a.endTime === b.endTime; } const CACHE_TTL_MS = 5 * 60 * 1e3; function isCacheValid(entry) { if (!entry) return false; return Date.now() - entry.fetchedAt < CACHE_TTL_MS; } function parseTimeDecimal(time) { const [hours, minutes = 0] = time.split(":").map(Number); if (!Number.isFinite(hours) || !Number.isFinite(minutes)) { return null; } return hours + minutes / 60; } function isTimeRangeConditionValue(value) { return typeof value === "object" && value !== null && !Array.isArray(value) && "start" in value && "end" in value && typeof value.start === "string" && typeof value.end === "string"; } function isDateRangeConditionValue(value) { return typeof value === "object" && value !== null && !Array.isArray(value) && "startDate" in value && "endDate" in value && typeof value.startDate === "string" && typeof value.endDate === "string"; } function applyPricingRules(baseUnitPrice, rules, dateString, timeslot) { let price = baseUnitPrice; const bookingDate = new Date(dateString); const dayOfWeek = bookingDate.getDay(); const slotTimeDecimal = parseTimeDecimal(timeslot.startTime); for (const rule of rules) { let ruleApplies = false; switch (rule.condition) { case "time_after": { if (typeof rule.conditionValue !== "string" || slotTimeDecimal === null) break; const threshold = parseTimeDecimal(rule.conditionValue); ruleApplies = threshold !== null && slotTimeDecimal >= threshold; break; } case "time_before": { if (typeof rule.conditionValue !== "string" || slotTimeDecimal === null) break; const threshold = parseTimeDecimal(rule.conditionValue); ruleApplies = threshold !== null && slotTimeDecimal < threshold; break; } case "time_between": { if (!isTimeRangeConditionValue(rule.conditionValue) || slotTimeDecimal === null) break; const startThreshold = parseTimeDecimal(rule.conditionValue.start); const endThreshold = parseTimeDecimal(rule.conditionValue.end); ruleApplies = startThreshold !== null && endThreshold !== null && slotTimeDecimal >= startThreshold && slotTimeDecimal < endThreshold; break; } case "day_of_week": { ruleApplies = Array.isArray(rule.conditionValue) && rule.conditionValue.includes(dayOfWeek); break; } case "date_range": { if (!isDateRangeConditionValue(rule.conditionValue)) break; ruleApplies = dateString >= rule.conditionValue.startDate && dateString <= rule.conditionValue.endDate; break; } } if (ruleApplies) { if (rule.modifier === "fixed") { price += rule.amount; } else if (rule.modifier === "percentage") { price += baseUnitPrice * (rule.amount / 100); } } } return clampMoney(price); } function calculatePrice(agenda, spots, pricingOptionId, addOnOrder, dateString, timeslot) { if (!agenda) return { basePrice: 0, addOnsPrice: 0, totalPrice: 0 }; let unitPrice = 0; const option = pricingOptionId ? agenda.pricingOptions?.find((p) => p.id === pricingOptionId) : agenda.pricingOptions?.find((p) => p.isDefault); if (option) { unitPrice = option.price; } if (dateString && timeslot && agenda.pricingRules && agenda.pricingRules.length > 0) { unitPrice = applyPricingRules(unitPrice, agenda.pricingRules, dateString, timeslot); } const basePrice = roundMoney(unitPrice * spots); let addOnsPrice = 0; const allAddOns = agenda.addOns || []; for (const [addOnId, value] of Object.entries(addOnOrder)) { if (!value) continue; const addOn = allAddOns.find((a) => a.id === addOnId); if (addOn) { switch (addOn.scope) { case "RESERVATION": addOnsPrice += addOn.price * 1; break; case "TICKET": addOnsPrice += addOn.price * Math.min(value || 1, spots); break; case "UNLIMITED": addOnsPrice += addOn.price * (value || 1); break; case "CUSTOM_LIMIT": addOnsPrice += addOn.price * Math.min(value || 1, addOn.limit || 1); break; default: addOnsPrice += addOn.price * 1; break; } } } addOnsPrice = roundMoney(addOnsPrice); return { basePrice, addOnsPrice, totalPrice: roundMoney(basePrice + addOnsPrice) }; } function timeToMinutes(time) { const [hours, minutes] = time.split(":").map(Number); return hours * 60 + minutes; } function minutesToTime(minutes) { const hours = Math.floor(minutes / 60); const mins = minutes % 60; return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`; } function timeslotsOverlap(slot1, slot2) { const start1 = timeToMinutes(slot1.startTime); const end1 = timeToMinutes(slot1.endTime); const start2 = timeToMinutes(slot2.startTime); const end2 = timeToMinutes(slot2.endTime); return start1 < end2 && start2 < end1; } function getMaybeRefValue(value) { if (value && typeof value === "object" && "value" in value) { return value.value; } return value; } function getReservationDate(reservation) { return getMaybeRefValue(reservation.date); } function getReservationTimeslot(reservation) { return getMaybeRefValue(reservation.hour); } function getReservationResource(reservation) { return getMaybeRefValue(reservation.resource); } function getReservationSpots(reservation) { return getMaybeRefValue(reservation.spots); } function getMaxConcurrentSpots(timeslot, intervals) { const rangeStart = timeToMinutes(timeslot.startTime); const rangeEnd = timeToMinutes(timeslot.endTime); const events = []; intervals.forEach((interval) => { const start = Math.max(timeToMinutes(interval.startTime), rangeStart); const end = Math.min(timeToMinutes(interval.endTime), rangeEnd); const spots = Math.max(Number(interval.spots) || 0, 0); if (start >= end || spots === 0) return; events.push({ minute: start, delta: spots }); events.push({ minute: end, delta: -spots }); }); events.sort((a, b) => a.minute - b.minute || a.delta - b.delta); let current = 0; let max = 0; events.forEach((event) => { current += event.delta; max = Math.max(max, current); }); return max; } function findException(agenda, resourceId, dateString) { const exceptions = agenda.exceptions || []; for (const exc of exceptions) { const dateInRange = dateString >= exc.startDate && dateString <= exc.endDate; if (!dateInRange) continue; if (exc.resourceIds === null) return exc; if (resourceId && exc.resourceIds?.includes(resourceId)) return exc; } return null; } function generateTimeSlotsForResource(resource, serviceDuration, date, dateString, agenda) { const exception = findException(agenda, resource.id, dateString); if (exception) { if (exception.isClosed) { return []; } const slots2 = []; if (exception.timeslots) { exception.timeslots.forEach((range) => { const startMinutes = timeToMinutes(range.startTime); const endMinutes = timeToMinutes(range.endTime); const interval = resource.interval || 30; for (let currentMinutes = startMinutes; currentMinutes < endMinutes; currentMinutes += interval) { const slotEndMinutes = currentMinutes + serviceDuration; if (slotEndMinutes <= endMinutes) { slots2.push({ startTime: minutesToTime(currentMinutes), endTime: minutesToTime(slotEndMinutes) }); } } }); } return slots2; } const dayOfWeek = date.getDay(); const openingHours = resource.openingHours?.[dayOfWeek] || []; if (openingHours.length === 0) { return []; } const slots = []; openingHours.forEach((range) => { const startMinutes = timeToMinutes(range.start); const endMinutes = timeToMinutes(range.end); const interval = resource.interval || 30; for (let currentMinutes = startMinutes; currentMinutes < endMinutes; currentMinutes += interval) { const slotEndMinutes = currentMinutes + serviceDuration; if (slotE