feat: conformite enonce - explorer, favoris, stats perso, tests, slots
Some checks failed
Deploy XIP / deploy (push) Failing after 37s
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user