@wallfar/ocd-studio-core-sdk
Version:
Helper SDK for our OneClick Studio modules
1,404 lines (1,395 loc) • 75.3 kB
JavaScript
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