feat: extract shop from mp/shop — initial libreshop/shop
Some checks failed
Build and publish / build (push) Failing after 19s
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.
This commit is contained in:
289
composables/useCart.ts
Normal file
289
composables/useCart.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user