Some checks failed
Build and publish / build (push) Failing after 19s
Source moved verbatim from mp/shop/ on 2026-04-29; mp was the first concrete adapter consuming the libreshop toolkit. Builds and publishes git.librete.ch/libreshop/shop on every main / v* push via the standard .gitea/workflows/build.yml shared across libreshop components.
290 lines
7.7 KiB
TypeScript
290 lines
7.7 KiB
TypeScript
import type { Cart, Order, CartProduct } from "~/types";
|
|
import { trackEvent } from "~/utils/trackEvent";
|
|
|
|
const CART_STORAGE_KEY = "shop:cart";
|
|
const MAX_RETRY_ATTEMPTS = 3;
|
|
const RETRY_DELAYS = [1000, 2000, 4000]; // Exponential backoff: 1s, 2s, 4s
|
|
|
|
// Module-level promise for deduplication (not reactive, just a lock)
|
|
let initPromise: Promise<void> | null = null;
|
|
|
|
export function useCart() {
|
|
const shopApi = useShopApi();
|
|
|
|
// Use useState for proper Nuxt SSR/client state management
|
|
const isInitialized = useState<boolean>("cart:initialized", () => false);
|
|
const isInitializing = useState<boolean>("cart:initializing", () => false);
|
|
const hasError = useState<boolean>("cart:hasError", () => false);
|
|
const retryCount = useState<number>("cart:retryCount", () => 0);
|
|
|
|
const cart = useState<Cart>("cart", () => ({
|
|
uuid: "",
|
|
products: [],
|
|
productsCount: 0,
|
|
total: 0,
|
|
subtotal: 0,
|
|
VAT: 0,
|
|
delivery: 0,
|
|
deliveryMethod: null,
|
|
emailAddress: "",
|
|
invoiceAddress: "",
|
|
deliveryAddress: "",
|
|
invoiceAddressStructured: null,
|
|
deliveryAddressStructured: null,
|
|
acceptedTermsAndConditionsAt: false
|
|
}));
|
|
|
|
function calculateCount(products: CartProduct[] | null) {
|
|
return products ? products.reduce((sum, { count }) => sum + count, 0) : 0;
|
|
}
|
|
|
|
function overwrite(order: Order) {
|
|
cart.value.products = (order.cart as CartProduct[] | null) ?? [];
|
|
cart.value.productsCount = calculateCount(cart.value.products);
|
|
cart.value.total = order.total ?? 0;
|
|
cart.value.subtotal = order.subtotal ?? 0;
|
|
cart.value.VAT = order.VAT ?? 0;
|
|
cart.value.delivery = order.delivery?.price ?? 0;
|
|
cart.value.deliveryMethod = order.delivery?.id ?? null;
|
|
cart.value.uuid = order.uuid ?? "";
|
|
cart.value.emailAddress = order.email ?? "";
|
|
cart.value.invoiceAddress = order.invoiceAddress ?? "";
|
|
cart.value.deliveryAddress = order.deliveryAddress ?? "";
|
|
cart.value.invoiceAddressStructured = order.invoiceAddressStructured ?? null;
|
|
cart.value.deliveryAddressStructured = order.deliveryAddressStructured ?? null;
|
|
cart.value.acceptedTermsAndConditionsAt = Boolean(order.acceptedTermsAndConditionsAt);
|
|
|
|
// Persist to localStorage (client-only)
|
|
if (import.meta.client) {
|
|
localStorage.setItem(CART_STORAGE_KEY, order.uuid);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sleep utility for retry delays
|
|
*/
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/**
|
|
* Reset cart to empty state
|
|
*/
|
|
function resetCart() {
|
|
cart.value = {
|
|
uuid: "",
|
|
products: [],
|
|
productsCount: 0,
|
|
total: 0,
|
|
subtotal: 0,
|
|
VAT: 0,
|
|
delivery: 0,
|
|
deliveryMethod: null,
|
|
emailAddress: "",
|
|
invoiceAddress: "",
|
|
deliveryAddress: "",
|
|
invoiceAddressStructured: null,
|
|
deliveryAddressStructured: null,
|
|
acceptedTermsAndConditionsAt: false
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Attempt to fetch cart with retry logic
|
|
*/
|
|
async function fetchWithRetry(attempt = 0): Promise<Order | null> {
|
|
const storedUuid = localStorage.getItem(CART_STORAGE_KEY);
|
|
|
|
try {
|
|
let order: Order | null = null;
|
|
|
|
if (storedUuid) {
|
|
try {
|
|
order = await shopApi.getOrder(storedUuid);
|
|
// If order is paid, we need a fresh cart
|
|
if (order?.paymentAuthorised) {
|
|
order = null;
|
|
}
|
|
} catch {
|
|
// Order not found or error - will create new one
|
|
order = null;
|
|
}
|
|
}
|
|
|
|
// Create new order if we don't have a valid one
|
|
if (!order) {
|
|
order = await shopApi.createOrder();
|
|
}
|
|
|
|
return order;
|
|
} catch (error) {
|
|
// Track retry attempt
|
|
if (attempt > 0) {
|
|
trackEvent("cart-fetch-retry", { attempt, maxAttempts: MAX_RETRY_ATTEMPTS });
|
|
}
|
|
|
|
// Check if we should retry
|
|
if (attempt < MAX_RETRY_ATTEMPTS - 1) {
|
|
const delay = RETRY_DELAYS[attempt] || RETRY_DELAYS[RETRY_DELAYS.length - 1];
|
|
console.warn(`Cart fetch attempt ${attempt + 1} failed, retrying in ${delay}ms...`);
|
|
await sleep(delay);
|
|
return fetchWithRetry(attempt + 1);
|
|
}
|
|
|
|
// All retries exhausted
|
|
trackEvent("cart-fetch-failed", { attempts: attempt + 1 });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function fetch() {
|
|
// Only run on client
|
|
if (!import.meta.client) return;
|
|
|
|
hasError.value = false;
|
|
retryCount.value = 0;
|
|
|
|
try {
|
|
const order = await fetchWithRetry();
|
|
if (order) {
|
|
overwrite(order);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching/creating cart after retries:", error);
|
|
localStorage.removeItem(CART_STORAGE_KEY);
|
|
hasError.value = true;
|
|
resetCart();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retry cart initialization after an error
|
|
*/
|
|
async function retry() {
|
|
if (!import.meta.client) return;
|
|
|
|
hasError.value = false;
|
|
isInitialized.value = false;
|
|
retryCount.value += 1;
|
|
|
|
trackEvent("cart-manual-retry", { retryCount: retryCount.value });
|
|
|
|
await initialize();
|
|
}
|
|
|
|
/**
|
|
* Initialize cart - ensures cart is fetched exactly once.
|
|
* Safe to call from multiple components; subsequent calls return the same promise.
|
|
*/
|
|
async function initialize() {
|
|
// Only run on client
|
|
if (!import.meta.client) return;
|
|
|
|
// Already initialized
|
|
if (isInitialized.value) return;
|
|
|
|
// Already initializing - wait for existing promise
|
|
if (isInitializing.value && initPromise) {
|
|
return initPromise;
|
|
}
|
|
|
|
// Start initialization
|
|
isInitializing.value = true;
|
|
initPromise = fetch().finally(() => {
|
|
isInitialized.value = true;
|
|
isInitializing.value = false;
|
|
});
|
|
|
|
return initPromise;
|
|
}
|
|
|
|
/**
|
|
* Wait for cart to be ready. Use this in components that need the cart.
|
|
*/
|
|
async function ensureReady() {
|
|
if (!import.meta.client) return;
|
|
|
|
if (!isInitialized.value) {
|
|
await initialize();
|
|
}
|
|
}
|
|
|
|
async function addProduct(productId: number, count = 1) {
|
|
// Ensure cart is initialized
|
|
if (!cart.value.uuid) {
|
|
await fetch();
|
|
}
|
|
|
|
// Verify we have a valid uuid before making the API call
|
|
if (!cart.value.uuid) {
|
|
throw new Error("Failed to initialize cart - no order UUID available");
|
|
}
|
|
|
|
const order = await shopApi.addProductToCart(cart.value.uuid, productId, count);
|
|
overwrite(order);
|
|
}
|
|
|
|
async function removeProduct(productId: number, count = 1) {
|
|
const order = await shopApi.removeProductFromCart(cart.value.uuid, productId, count);
|
|
overwrite(order);
|
|
}
|
|
|
|
async function update(data: Partial<Order>) {
|
|
const order = await shopApi.updateOrder(cart.value.uuid, data);
|
|
overwrite(order);
|
|
}
|
|
|
|
async function checkout() {
|
|
return await shopApi.checkoutOrder(cart.value.uuid);
|
|
}
|
|
|
|
// Expose individual computed refs for easier access in components
|
|
const uuid = computed(() => cart.value.uuid);
|
|
const products = computed(() => cart.value.products);
|
|
const productsCount = computed(() => cart.value.productsCount);
|
|
const total = computed(() => cart.value.total);
|
|
const subtotal = computed(() => cart.value.subtotal);
|
|
const VAT = computed(() => cart.value.VAT);
|
|
const delivery = computed(() => cart.value.delivery);
|
|
const deliveryMethod = computed(() => cart.value.deliveryMethod);
|
|
const emailAddress = computed(() => cart.value.emailAddress);
|
|
const invoiceAddress = computed(() => cart.value.invoiceAddress);
|
|
const deliveryAddress = computed(() => cart.value.deliveryAddress);
|
|
const invoiceAddressStructured = computed(() => cart.value.invoiceAddressStructured);
|
|
const deliveryAddressStructured = computed(() => cart.value.deliveryAddressStructured);
|
|
const acceptedTermsAndConditionsAt = computed(() => cart.value.acceptedTermsAndConditionsAt);
|
|
|
|
return {
|
|
// State
|
|
isInitialized,
|
|
isInitializing,
|
|
hasError,
|
|
retryCount,
|
|
// Individual computed refs for easy template access
|
|
uuid,
|
|
products,
|
|
productsCount,
|
|
total,
|
|
subtotal,
|
|
VAT,
|
|
delivery,
|
|
deliveryMethod,
|
|
emailAddress,
|
|
invoiceAddress,
|
|
deliveryAddress,
|
|
invoiceAddressStructured,
|
|
deliveryAddressStructured,
|
|
acceptedTermsAndConditionsAt,
|
|
// Methods
|
|
initialize,
|
|
ensureReady,
|
|
fetch,
|
|
retry,
|
|
overwrite,
|
|
addProduct,
|
|
removeProduct,
|
|
update,
|
|
checkout
|
|
};
|
|
}
|