feat: extract shop from mp/shop — initial libreshop/shop
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:
Michael Czechowski
2026-04-29 17:48:56 +02:00
commit 44107c0734
134 changed files with 19521 additions and 0 deletions

289
composables/useCart.ts Normal file
View 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
};
}