feat: conformite enonce - explorer, favoris, stats perso, tests, slots
Some checks failed
Deploy XIP / deploy (push) Failing after 37s

Fonctionnel
- Backend messages : GET /api/messages/:id (detail) + recherche (q),
  pagination par curseur (before/limit) avec enveloppe { items, nextCursor,
  hasMore } ; le flux temps reel garde l'ancien format quand aucun parametre.
- Explorer (/explorer) : catalogue distant, recherche debouncee + annulable
  (AbortController), filtre, defilement infini, etat garde (keep-alive).
- Details par id : /message/:id et /shop/p/:id (consomment route.params).
- Favoris (/favoris) : liste perso persistee en localStorage, notation
  (note/rating/statut) via modale, refletee partout (bouton favori).
- Mes stats (/mes-stats) : agregats derives des favoris (note moyenne, top
  pays/auteurs, statuts), auto-mis a jour, route gardee si liste vide.
- Routeur : pages secondaires en lazy-load + repli, garde beforeEnter.

Technique
- Slots : PrefSection (slot defaut + slot nomme) enveloppe les 5 sections
  "Mes Persos" ; Modal (Teleport + slots).
- v-model custom : SearchBox (defineModel + debounce).
- Directive custom : v-click-outside.
- Tests Vitest : 25 tests (etat, fonctions, composants), ~86% du code metier.
- Retrait d'Ionic (inutilise). Script typecheck backend ; tsconfig @types/bun.
- Correctif type : garde stockLimit nullable dans l'achat (catalog.ts).
- README complet (URL, stack, run, tests, secrets, deploiement, mention IA).
This commit is contained in:
2026-05-31 23:57:00 +02:00
committed by kerboul
parent 9dd72b9b2d
commit cfa2eadec9
111 changed files with 9634 additions and 7875 deletions

View File

@@ -1,330 +1,331 @@
<!-- One marketplace product card handles per-kind options inline (faithful to shop mockups) -->
<template>
<div class="card" :class="{ 'card--owned': ownedAlready }">
<div v-if="product.badge" class="card-badge">{{ product.badge }}</div>
<div class="card-head">
<span class="card-icon">{{ icon }}</span>
<div>
<p class="card-name">{{ product.name }}</p>
<p v-if="product.subtitle" class="card-sub">{{ product.subtitle }}</p>
</div>
</div>
<!-- Aperçu cosmétique : avant / après -->
<div v-if="product.kind === 'ip-skin' || product.id === 'bundle-cosmetic'" class="preview">
<span class="prev-ip prev-plain">192.168.1.45</span>
<span class="prev-arrow"></span>
<span class="prev-ip prev-gold">192.168.1.45</span>
</div>
<!-- Options : abonnement NoAds -->
<div v-if="product.kind === 'subscription'" class="opts">
<label v-for="p in plans" :key="p.id" class="opt-radio" :class="{ active: plan === p.id }">
<input type="radio" :value="p.id" v-model="plan" />
<span>{{ p.label }}</span>
<span class="opt-price">{{ fmt(p.price) }} cr{{ p.id === 'monthly' ? '/mois' : '/an' }}</span>
</label>
</div>
<!-- Options : Cadre de Pub -->
<div v-if="product.kind === 'ad-frame'" class="opts">
<div class="opt-row">
<span class="opt-label">Durée</span>
<select v-model.number="durationDays" class="opt-select">
<option v-for="d in durations" :key="d.days" :value="d.days">
{{ d.days }} j{{ d.extra ? ` (+${fmt(d.extra)})` : '' }}
</option>
</select>
</div>
<div class="opt-row">
<span class="opt-label">Format</span>
<select v-model="format" class="opt-select">
<option v-for="f in formats" :key="f.id" :value="f.id">
{{ f.label }}{{ f.extra ? ` (+${fmt(f.extra)})` : '' }}
</option>
</select>
</div>
<input v-model="url" class="opt-input" type="text" placeholder="URL de destination (optionnel)" />
</div>
<!-- Options : Pet (grille des designs non encore possédés) -->
<div v-if="product.kind === 'pet'" class="opts">
<div class="pet-grid">
<button
v-for="d in availableDesigns"
:key="d.id"
class="pet-cell"
:class="{ active: petDesign === d.id }"
@click="petDesign = d.id"
type="button"
>{{ d.char }}</button>
</div>
</div>
<!-- Preview : Skin de bouton -->
<div v-if="product.kind === 'send-skin'" class="send-skin-preview">
<div class="skin-btn-demo">{{ meta.char }}</div>
</div>
<!-- Stock limité -->
<div v-if="product.stockLimit" class="stock">
<div class="stock-bar"><div class="stock-fill" :style="{ width: stockPct + '%' }" /></div>
<span class="stock-txt">{{ product.stockSold }} / {{ product.stockLimit }} vendus</span>
</div>
<!-- Prix + CTA -->
<div class="card-foot">
<div class="price">
<span v-if="product.promoPrice != null" class="price-old">{{ fmt(product.basePrice) }}</span>
<span class="price-now">{{ fmt(effectivePrice) }}</span>
<span class="price-unit">cr</span>
</div>
<!-- Pets: bouton acheter + lien Mes Persos -->
<template v-if="product.kind === 'pet'">
<button
class="buy"
:disabled="disabled"
@click="onBuy"
type="button"
>{{ buyLabel }}</button>
<button
class="buy buy--perso"
@click="$emit('goPerso')"
type="button"
> Mes Persos</button>
</template>
<button
v-else
class="buy"
:disabled="disabled"
@click="onBuy"
type="button"
>{{ buyLabel }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { Product, PurchaseOptions } from '@/composables/useShop';
import { parseMeta, type ProductMeta } from '@/composables/useMeta';
const props = defineProps<{
product: Product;
buying: boolean;
owns: (kind: string) => boolean;
ownedPetChars: string[];
petCount: number;
freeMode: boolean;
}>();
const emit = defineEmits<{
buy: [productId: string, options: PurchaseOptions];
goPerso: [];
}>();
const meta = computed(() => parseMeta<ProductMeta>(props.product.metaJson));
// Subscription
const plans = computed(() => meta.value.plans ?? []);
const plan = ref<'monthly' | 'annual'>('monthly');
// Ad-frame
const durations = computed(() => meta.value.durations ?? []);
const formats = computed(() => meta.value.formats ?? []);
const durationDays = ref<number>(7);
const format = ref<'static' | 'gif'>('static');
const url = ref('');
// Pet
const designs = computed(() => meta.value.designs ?? []);
const petDesign = ref<string>('');
const availableDesigns = computed(() =>
designs.value.filter((d) => !props.ownedPetChars.includes(d.char))
);
watch(availableDesigns, (ds) => {
if (ds.length > 0 && !ds.find((d) => d.id === petDesign.value)) {
petDesign.value = ds[0].id;
}
}, { immediate: true });
const icon = computed(() => {
if (props.product.id === 'ip-colors') return '🎨';
if (props.product.kind === 'send-skin') return meta.value.char ?? '🖱️';
switch (props.product.kind) {
case 'ad-frame': return '📣';
case 'subscription': return '🚫';
case 'ip-skin': return '👑';
case 'pet': return '✨';
case 'bundle': return '🎁';
case 'rich': return props.product.id === 'rich-js' ? '⚡' : '🎨';
case 'consumable': return '🔊';
default: return '🛍️';
}
});
const effectivePrice = computed(() => {
let price = props.product.promoPrice ?? props.product.basePrice;
if (props.product.kind === 'subscription') {
const p = plans.value.find((x) => x.id === plan.value);
if (p) price = p.price;
}
if (props.product.kind === 'ad-frame') {
const d = durations.value.find((x) => x.days === durationDays.value);
const f = formats.value.find((x) => x.id === format.value);
price += (d?.extra ?? 0) + (f?.extra ?? 0);
}
return price;
});
// Ownership / limits → disable & label.
const ownedAlready = computed(() => {
const k = props.product.kind;
if (k === 'ip-skin') return props.owns('style-dore');
if (k === 'subscription') return props.owns('noads');
if (k === 'rich') return props.owns(props.product.id);
if (k === 'unlock') return props.owns(props.product.id);
if (k === 'ad-frame') return props.owns('ad-frame');
if (k === 'send-skin') return props.owns(props.product.id);
return false;
});
const soldOut = computed(() => props.product.stockLimit != null && props.product.stockSold >= props.product.stockLimit);
const disabled = computed(() => props.buying || ownedAlready.value || soldOut.value);
const buyLabel = computed(() => {
if (props.buying) return '...';
if (soldOut.value) return 'Épuisé';
if (ownedAlready.value) return 'Possédé ✓';
return props.freeMode ? 'Obtenir (gratuit)' : 'Acheter';
});
const stockPct = computed(() =>
props.product.stockLimit ? Math.round((props.product.stockSold / props.product.stockLimit) * 100) : 0
);
function fmt(centi: number): string {
return (centi / 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function onBuy(): void {
const options: PurchaseOptions = {};
if (props.product.kind === 'subscription') options.plan = plan.value;
if (props.product.kind === 'ad-frame') {
options.durationDays = durationDays.value;
options.format = format.value;
options.url = url.value || undefined;
}
if (props.product.kind === 'pet' || props.product.id === 'bundle-cosmetic') {
const d = availableDesigns.value.find((x) => x.id === petDesign.value) ?? availableDesigns.value[0];
if (d) { options.petDesign = d.id; options.petChar = d.char; }
}
emit('buy', props.product.id, options);
}
</script>
<style scoped>
.card {
position: relative;
background: #101018;
border: 1px solid #20203a;
border-radius: 10px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
font-family: Arial, sans-serif;
}
.card--owned { opacity: 0.7; }
.card-badge {
position: absolute;
top: -9px;
right: 12px;
background: #ff2266;
color: #fff;
font-size: 9px;
font-weight: bold;
letter-spacing: 0.5px;
padding: 3px 9px;
border-radius: 8px;
box-shadow: none;
}
.card-head { display: flex; gap: 12px; align-items: flex-start; }
.card-icon { font-size: 28px; }
.card-name { font-size: 15px; font-weight: bold; color: #d8d8ee; margin: 0; }
.card-sub { font-size: 11px; color: #6a6a90; margin: 3px 0 0; line-height: 1.4; }
.preview {
display: flex; align-items: center; gap: 10px;
background: #0a0a12; border-radius: 6px; padding: 10px; justify-content: center;
}
.prev-ip { font-family: 'Courier New', monospace; font-size: 12px; font-weight: bold; }
.prev-plain { color: #666688; }
.prev-gold { color: #aa8833; }
.prev-arrow { color: #444466; }
.opts { display: flex; flex-direction: column; gap: 8px; }
.opt-radio {
display: flex; align-items: center; gap: 8px;
background: #0c0c16; border: 1px solid #20203a; border-radius: 6px;
padding: 8px 10px; font-size: 12px; color: #aaaacc; cursor: pointer;
}
.opt-radio.active { border-color: #00aaff; background: #0a1622; }
.opt-radio input { accent-color: #00ccff; }
.opt-radio--sm { padding: 5px 8px; font-size: 11px; flex: 1; justify-content: center; }
.opt-price { margin-left: auto; color: #ffdd66; font-family: 'Courier New', monospace; }
.opt-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.opt-label { font-size: 11px; color: #8888aa; }
.opt-select, .opt-input {
background: #0c0c16; border: 1px solid #20203a; border-radius: 6px;
color: #ccccdd; font-size: 12px; padding: 6px 8px; outline: none;
}
.opt-select { flex: 1; }
.opt-input { width: 100%; }
.pet-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; }
.pet-cell {
aspect-ratio: 1; background: #0c0c16; border: 1px solid #20203a; border-radius: 6px;
font-size: 18px; color: #ccccee; cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.pet-cell.active { border-color: #8844aa; }
.pet-pos { display: flex; gap: 6px; }
.send-skin-preview { display: flex; justify-content: center; padding: 8px 0; }
.skin-btn-demo {
width: 52px; height: 52px; border-radius: 50%;
background: #151525; border: 1px solid #30306a;
display: flex; align-items: center; justify-content: center;
font-size: 24px;
}
.stock { display: flex; flex-direction: column; gap: 4px; }
.stock-bar { height: 6px; background: #1a1a2a; border-radius: 3px; overflow: hidden; }
.stock-fill { height: 100%; background: linear-gradient(90deg, #ffaa00, #ff4444); }
.stock-txt { font-size: 10px; color: #886644; }
.card-foot { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 6px; margin-top: auto; padding-top: 6px; }
.price { display: flex; align-items: baseline; gap: 6px; }
.price-old { font-size: 12px; color: #555; text-decoration: line-through; }
.price-now { font-size: 20px; font-weight: bold; color: #ccaa44; font-family: 'Courier New', monospace; }
.price-unit { font-size: 11px; color: #886633; }
.buy {
background: #004488; border: 1px solid #0066aa; color: #00ddff;
font-size: 13px; font-weight: bold; padding: 9px 18px; border-radius: 20px; cursor: pointer;
transition: background 0.15s;
}
.buy:hover:not(:disabled) { background: #1a4466; }
.buy:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; }
.buy--perso {
background: #1a1030; border: 1px solid #8844cc; color: #cc88ff;
font-size: 13px; font-weight: bold; padding: 9px 18px; border-radius: 20px; cursor: pointer;
transition: background 0.15s;
}
.buy--perso:hover { background: #261844; }
</style>
<!-- One marketplace product card handles per-kind options inline (faithful to shop mockups) -->
<template>
<div class="card" :class="{ 'card--owned': ownedAlready }">
<div v-if="product.badge" class="card-badge">{{ product.badge }}</div>
<div class="card-head">
<span class="card-icon">{{ icon }}</span>
<div>
<RouterLink :to="`/shop/p/${product.id}`" class="card-name">{{ product.name }}</RouterLink>
<p v-if="product.subtitle" class="card-sub">{{ product.subtitle }}</p>
</div>
</div>
<!-- Aperçu cosmétique : avant / après -->
<div v-if="product.kind === 'ip-skin' || product.id === 'bundle-cosmetic'" class="preview">
<span class="prev-ip prev-plain">192.168.1.45</span>
<span class="prev-arrow"></span>
<span class="prev-ip prev-gold">192.168.1.45</span>
</div>
<!-- Options : abonnement NoAds -->
<div v-if="product.kind === 'subscription'" class="opts">
<label v-for="p in plans" :key="p.id" class="opt-radio" :class="{ active: plan === p.id }">
<input type="radio" :value="p.id" v-model="plan" />
<span>{{ p.label }}</span>
<span class="opt-price">{{ fmt(p.price) }} cr{{ p.id === 'monthly' ? '/mois' : '/an' }}</span>
</label>
</div>
<!-- Options : Cadre de Pub -->
<div v-if="product.kind === 'ad-frame'" class="opts">
<div class="opt-row">
<span class="opt-label">Durée</span>
<select v-model.number="durationDays" class="opt-select">
<option v-for="d in durations" :key="d.days" :value="d.days">
{{ d.days }} j{{ d.extra ? ` (+${fmt(d.extra)})` : '' }}
</option>
</select>
</div>
<div class="opt-row">
<span class="opt-label">Format</span>
<select v-model="format" class="opt-select">
<option v-for="f in formats" :key="f.id" :value="f.id">
{{ f.label }}{{ f.extra ? ` (+${fmt(f.extra)})` : '' }}
</option>
</select>
</div>
<input v-model="url" class="opt-input" type="text" placeholder="URL de destination (optionnel)" />
</div>
<!-- Options : Pet (grille des designs non encore possédés) -->
<div v-if="product.kind === 'pet'" class="opts">
<div class="pet-grid">
<button
v-for="d in availableDesigns"
:key="d.id"
class="pet-cell"
:class="{ active: petDesign === d.id }"
@click="petDesign = d.id"
type="button"
>{{ d.char }}</button>
</div>
</div>
<!-- Preview : Skin de bouton -->
<div v-if="product.kind === 'send-skin'" class="send-skin-preview">
<div class="skin-btn-demo">{{ meta.char }}</div>
</div>
<!-- Stock limité -->
<div v-if="product.stockLimit" class="stock">
<div class="stock-bar"><div class="stock-fill" :style="{ width: stockPct + '%' }" /></div>
<span class="stock-txt">{{ product.stockSold }} / {{ product.stockLimit }} vendus</span>
</div>
<!-- Prix + CTA -->
<div class="card-foot">
<div class="price">
<span v-if="product.promoPrice != null" class="price-old">{{ fmt(product.basePrice) }}</span>
<span class="price-now">{{ fmt(effectivePrice) }}</span>
<span class="price-unit">cr</span>
</div>
<!-- Pets: bouton acheter + lien Mes Persos -->
<template v-if="product.kind === 'pet'">
<button
class="buy"
:disabled="disabled"
@click="onBuy"
type="button"
>{{ buyLabel }}</button>
<button
class="buy buy--perso"
@click="$emit('goPerso')"
type="button"
> Mes Persos</button>
</template>
<button
v-else
class="buy"
:disabled="disabled"
@click="onBuy"
type="button"
>{{ buyLabel }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { Product, PurchaseOptions } from '@/composables/useShop';
import { parseMeta, type ProductMeta } from '@/composables/useMeta';
const props = defineProps<{
product: Product;
buying: boolean;
owns: (kind: string) => boolean;
ownedPetChars: string[];
petCount: number;
freeMode: boolean;
}>();
const emit = defineEmits<{
buy: [productId: string, options: PurchaseOptions];
goPerso: [];
}>();
const meta = computed(() => parseMeta<ProductMeta>(props.product.metaJson));
// Subscription
const plans = computed(() => meta.value.plans ?? []);
const plan = ref<'monthly' | 'annual'>('monthly');
// Ad-frame
const durations = computed(() => meta.value.durations ?? []);
const formats = computed(() => meta.value.formats ?? []);
const durationDays = ref<number>(7);
const format = ref<'static' | 'gif'>('static');
const url = ref('');
// Pet
const designs = computed(() => meta.value.designs ?? []);
const petDesign = ref<string>('');
const availableDesigns = computed(() =>
designs.value.filter((d) => !props.ownedPetChars.includes(d.char))
);
watch(availableDesigns, (ds) => {
if (ds.length > 0 && !ds.find((d) => d.id === petDesign.value)) {
petDesign.value = ds[0].id;
}
}, { immediate: true });
const icon = computed(() => {
if (props.product.id === 'ip-colors') return '🎨';
if (props.product.kind === 'send-skin') return meta.value.char ?? '🖱️';
switch (props.product.kind) {
case 'ad-frame': return '📣';
case 'subscription': return '🚫';
case 'ip-skin': return '👑';
case 'pet': return '✨';
case 'bundle': return '🎁';
case 'rich': return props.product.id === 'rich-js' ? '⚡' : '🎨';
case 'consumable': return '🔊';
default: return '🛍️';
}
});
const effectivePrice = computed(() => {
let price = props.product.promoPrice ?? props.product.basePrice;
if (props.product.kind === 'subscription') {
const p = plans.value.find((x) => x.id === plan.value);
if (p) price = p.price;
}
if (props.product.kind === 'ad-frame') {
const d = durations.value.find((x) => x.days === durationDays.value);
const f = formats.value.find((x) => x.id === format.value);
price += (d?.extra ?? 0) + (f?.extra ?? 0);
}
return price;
});
// Ownership / limits → disable & label.
const ownedAlready = computed(() => {
const k = props.product.kind;
if (k === 'ip-skin') return props.owns('style-dore');
if (k === 'subscription') return props.owns('noads');
if (k === 'rich') return props.owns(props.product.id);
if (k === 'unlock') return props.owns(props.product.id);
if (k === 'ad-frame') return props.owns('ad-frame');
if (k === 'send-skin') return props.owns(props.product.id);
return false;
});
const soldOut = computed(() => props.product.stockLimit != null && props.product.stockSold >= props.product.stockLimit);
const disabled = computed(() => props.buying || ownedAlready.value || soldOut.value);
const buyLabel = computed(() => {
if (props.buying) return '...';
if (soldOut.value) return 'Épuisé';
if (ownedAlready.value) return 'Possédé ✓';
return props.freeMode ? 'Obtenir (gratuit)' : 'Acheter';
});
const stockPct = computed(() =>
props.product.stockLimit ? Math.round((props.product.stockSold / props.product.stockLimit) * 100) : 0
);
function fmt(centi: number): string {
return (centi / 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function onBuy(): void {
const options: PurchaseOptions = {};
if (props.product.kind === 'subscription') options.plan = plan.value;
if (props.product.kind === 'ad-frame') {
options.durationDays = durationDays.value;
options.format = format.value;
options.url = url.value || undefined;
}
if (props.product.kind === 'pet' || props.product.id === 'bundle-cosmetic') {
const d = availableDesigns.value.find((x) => x.id === petDesign.value) ?? availableDesigns.value[0];
if (d) { options.petDesign = d.id; options.petChar = d.char; }
}
emit('buy', props.product.id, options);
}
</script>
<style scoped>
.card {
position: relative;
background: #101018;
border: 1px solid #20203a;
border-radius: 10px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
font-family: Arial, sans-serif;
}
.card--owned { opacity: 0.7; }
.card-badge {
position: absolute;
top: -9px;
right: 12px;
background: #ff2266;
color: #fff;
font-size: 9px;
font-weight: bold;
letter-spacing: 0.5px;
padding: 3px 9px;
border-radius: 8px;
box-shadow: none;
}
.card-head { display: flex; gap: 12px; align-items: flex-start; }
.card-icon { font-size: 28px; }
.card-name { font-size: 15px; font-weight: bold; color: #d8d8ee; margin: 0; text-decoration: none; display: inline-block; }
.card-name:hover { color: #00ddff; }
.card-sub { font-size: 11px; color: #6a6a90; margin: 3px 0 0; line-height: 1.4; }
.preview {
display: flex; align-items: center; gap: 10px;
background: #0a0a12; border-radius: 6px; padding: 10px; justify-content: center;
}
.prev-ip { font-family: 'Courier New', monospace; font-size: 12px; font-weight: bold; }
.prev-plain { color: #666688; }
.prev-gold { color: #aa8833; }
.prev-arrow { color: #444466; }
.opts { display: flex; flex-direction: column; gap: 8px; }
.opt-radio {
display: flex; align-items: center; gap: 8px;
background: #0c0c16; border: 1px solid #20203a; border-radius: 6px;
padding: 8px 10px; font-size: 12px; color: #aaaacc; cursor: pointer;
}
.opt-radio.active { border-color: #00aaff; background: #0a1622; }
.opt-radio input { accent-color: #00ccff; }
.opt-radio--sm { padding: 5px 8px; font-size: 11px; flex: 1; justify-content: center; }
.opt-price { margin-left: auto; color: #ffdd66; font-family: 'Courier New', monospace; }
.opt-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.opt-label { font-size: 11px; color: #8888aa; }
.opt-select, .opt-input {
background: #0c0c16; border: 1px solid #20203a; border-radius: 6px;
color: #ccccdd; font-size: 12px; padding: 6px 8px; outline: none;
}
.opt-select { flex: 1; }
.opt-input { width: 100%; }
.pet-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; }
.pet-cell {
aspect-ratio: 1; background: #0c0c16; border: 1px solid #20203a; border-radius: 6px;
font-size: 18px; color: #ccccee; cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.pet-cell.active { border-color: #8844aa; }
.pet-pos { display: flex; gap: 6px; }
.send-skin-preview { display: flex; justify-content: center; padding: 8px 0; }
.skin-btn-demo {
width: 52px; height: 52px; border-radius: 50%;
background: #151525; border: 1px solid #30306a;
display: flex; align-items: center; justify-content: center;
font-size: 24px;
}
.stock { display: flex; flex-direction: column; gap: 4px; }
.stock-bar { height: 6px; background: #1a1a2a; border-radius: 3px; overflow: hidden; }
.stock-fill { height: 100%; background: linear-gradient(90deg, #ffaa00, #ff4444); }
.stock-txt { font-size: 10px; color: #886644; }
.card-foot { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 6px; margin-top: auto; padding-top: 6px; }
.price { display: flex; align-items: baseline; gap: 6px; }
.price-old { font-size: 12px; color: #555; text-decoration: line-through; }
.price-now { font-size: 20px; font-weight: bold; color: #ccaa44; font-family: 'Courier New', monospace; }
.price-unit { font-size: 11px; color: #886633; }
.buy {
background: #004488; border: 1px solid #0066aa; color: #00ddff;
font-size: 13px; font-weight: bold; padding: 9px 18px; border-radius: 20px; cursor: pointer;
transition: background 0.15s;
}
.buy:hover:not(:disabled) { background: #1a4466; }
.buy:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; }
.buy--perso {
background: #1a1030; border: 1px solid #8844cc; color: #cc88ff;
font-size: 13px; font-weight: bold; padding: 9px 18px; border-radius: 20px; cursor: pointer;
transition: background 0.15s;
}
.buy--perso:hover { background: #261844; }
</style>