All checks were successful
Deploy XIP / deploy (push) Successful in 43s
Theming - Thème global piloté par variables CSS (:root + [data-theme]) appliqué via un attribut data-theme sur la racine app. Ajout du thème "WhatsApp" (bulles + palette verte, bulle sortante #005c4b) sans nouveau composant message. - useTheme: type Theme étendu + THEME_LAYOUT (whatsapp = layout bulles). - MessageList: sélection du composant par layout avec garde de repli (fini le <component :is="undefined">). - Fix du thème "compact" cassé : nouveau MessageItemCompact.vue (variante dense). - Surfaces migrées en variables : fond app/chat, header, bouton d'envoi, bulles. Corrections - Bug envoi rich/fichier : le backend exigeait un content texte non vide même en mode HTML/CSS/JS. Validation par présence (texte OU rich OU piece jointe) ; le front n'envoie plus d'espace bidon. Plus besoin de faux texte. - Shop : suppression de "Tout voir", navigation forcee par categorie (defaut: Publicite). Refactor (lisibilite) - Parite perks backend (ip-colors, audio-alert, send-skin-*) ; /api/shop/me renvoie myPerks precalcule ; le front consomme directement (suppression de la derivation dupliquee + nettoyage d'un artefact de merge dans useMessages). - Coherence composable-singleton : myPerks lu via useMyPerks() partout. - Extraction du composer de HomePage vers ChatComposer.vue (HomePage = layout). - Helper type parseMeta<T>() pour les metaJson (moins de any). - vue-tsc --noEmit : 0 erreur. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
134 lines
3.5 KiB
TypeScript
134 lines
3.5 KiB
TypeScript
import { ref } from 'vue';
|
|
import { useWallet } from './useWallet';
|
|
import { refreshMyPerks } from './useMessages';
|
|
import { parseMeta, type ProductMeta } from './useMeta';
|
|
|
|
/** Marketplace client: catalogue, my entitlements, purchase flow. */
|
|
|
|
export interface Product {
|
|
id: string;
|
|
category: string;
|
|
name: string;
|
|
subtitle?: string | null;
|
|
kind: string;
|
|
basePrice: number; // centi-credits
|
|
promoPrice?: number | null;
|
|
badge?: string | null;
|
|
stockLimit?: number | null;
|
|
stockSold: number;
|
|
sortOrder: number;
|
|
metaJson?: string | null;
|
|
}
|
|
|
|
export interface Entitlement {
|
|
id: string;
|
|
ip: string;
|
|
kind: string;
|
|
active: boolean;
|
|
expiresAt?: string | null;
|
|
metaJson?: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface PurchaseOptions {
|
|
plan?: 'monthly' | 'annual';
|
|
durationDays?: number;
|
|
format?: 'static' | 'gif';
|
|
url?: string;
|
|
petDesign?: string;
|
|
petChar?: string;
|
|
petPosition?: 'left' | 'right' | 'both';
|
|
}
|
|
|
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
|
|
|
|
export function useShop() {
|
|
const products = ref<Product[]>([]);
|
|
const entitlements = ref<Entitlement[]>([]);
|
|
const loading = ref(false);
|
|
const buying = ref<string | null>(null); // productId currently being purchased
|
|
const lastError = ref<string | null>(null);
|
|
const lastSuccess = ref<string | null>(null);
|
|
|
|
const { fetchWallet } = useWallet();
|
|
|
|
async function fetchProducts(): Promise<void> {
|
|
loading.value = true;
|
|
try {
|
|
const res = await fetch(`${API_URL}/api/shop/products`);
|
|
if (res.ok) products.value = (await res.json()) as Product[];
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function fetchMe(): Promise<void> {
|
|
try {
|
|
const res = await fetch(`${API_URL}/api/shop/me`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
entitlements.value = data.entitlements ?? [];
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
function owns(kind: string): boolean {
|
|
return entitlements.value.some((e) => e.kind === kind && e.active);
|
|
}
|
|
|
|
function petCount(): number {
|
|
return entitlements.value.filter((e) => e.kind === 'pet' && e.active).length;
|
|
}
|
|
|
|
function ownedPetChars(): string[] {
|
|
return entitlements.value
|
|
.filter((e) => e.kind === 'pet' && e.active)
|
|
.map((e) => parseMeta<ProductMeta>(e.metaJson).char ?? '')
|
|
.filter(Boolean);
|
|
}
|
|
|
|
async function purchase(productId: string, options: PurchaseOptions = {}): Promise<boolean> {
|
|
buying.value = productId;
|
|
lastError.value = null;
|
|
lastSuccess.value = null;
|
|
try {
|
|
const res = await fetch(`${API_URL}/api/shop/purchase`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ productId, options }),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
lastError.value = data.error || 'Achat impossible';
|
|
return false;
|
|
}
|
|
lastSuccess.value = `Acheté : ${productId}`;
|
|
// Refresh wallet + my entitlements + myPerks (WS also pushes wallet, this is belt-and-braces).
|
|
await Promise.all([fetchWallet(), fetchMe(), fetchProducts(), refreshMyPerks()]);
|
|
return true;
|
|
} catch {
|
|
lastError.value = 'Réseau indisponible';
|
|
return false;
|
|
} finally {
|
|
buying.value = null;
|
|
}
|
|
}
|
|
|
|
return {
|
|
products,
|
|
entitlements,
|
|
loading,
|
|
buying,
|
|
lastError,
|
|
lastSuccess,
|
|
fetchProducts,
|
|
fetchMe,
|
|
owns,
|
|
petCount,
|
|
ownedPetChars,
|
|
purchase,
|
|
};
|
|
}
|