feat: marketplace, économie à crédits, perks temps réel & pubs réelles
Transforme XIP en réseau social satirique complet : monnaie fictive, marketplace, cosmétiques visibles de tous, messages riches sandboxés, pubs pilotées par les données, et tous les compteurs mock rendus réels. Backend (Bun + Hono + Prisma + Redis) - Économie par IP : modèles Wallet/Purchase/Entitlement, lib/wallet.ts avec spend() atomique (point unique du paywall) + recharge gratuite. - isLocalhost() → mode gratuit (README « si localhost: pas de paywall »). - Marketplace : lib/catalog.ts (achat transactionnel, stock limité, limites par IP) + routes/shop.ts ; 10 produits seedés (idempotent). - Perks : lib/perks.ts (cache Redis busté à l'achat) ; authorPerks injecté dans les payloads messages + endpoint batch /api/perks ; frame WS « perks » global pour MAJ live des messages déjà affichés. - Messages riches : Message.richMode/richContent, gating par entitlement. - Pubs réelles : modèle Ad seedé avec les 4 pubs (ex-hardcodées), rotation par API, comptage d'impressions réel + réconciliation. - WebSocket : IP capturée par connexion → broadcastToIp / broadcast ; frames wallet/perks/ads/alert. - Pièces jointes : lib/storage.ts (UUID, jamais exécuté) + routes/uploads.ts (limite 1 Mo sauf déblocage/localhost, Content-Disposition: attachment). - Alerte audio : routes/alert.ts (cooldown serveur Redis NX, clamp durée). - Compteur « argent extorqué » réel : impressions×CPM + crédits dépensés. Frontend (Vue 3 + Vite) - /shop : ShopPage + ProductCard fidèles aux maquettes ; composables useWallet/useShop/usePerks/useAds/useAttachments/useAlert. - UI de réponse (bannière + sous-threads), solde + lien Shop dans le header. - Perks rendus : Style Doré (or), Pets autour de l'IP, NoAds masque les pubs. - RichContent.vue : iframe sandbox verrouillée (htmlcss sans script ; js allow-scripts seul, jamais allow-same-origin) + CSP. - AdBand/InlineCasinoAd pilotés par l'API ; barre de saisie avec 📎, compteur de caractères, composer riche et bouton alerte. Infra - Migration economy_ads_attachments_rich ; seed idempotent (produits+pubs). - vite.config : usePolling (HMR fiable sur /mnt/c via WSL). - backend/.gitignore : uploads/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
296
frontend/src/components/shop/ProductCard.vue
Normal file
296
frontend/src/components/shop/ProductCard.vue
Normal file
@@ -0,0 +1,296 @@
|
||||
<!-- 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 + position) -->
|
||||
<div v-if="product.kind === 'pet'" class="opts">
|
||||
<div class="pet-grid">
|
||||
<button
|
||||
v-for="d in designs"
|
||||
:key="d.id"
|
||||
class="pet-cell"
|
||||
:class="{ active: petDesign === d.id }"
|
||||
@click="petDesign = d.id"
|
||||
type="button"
|
||||
>{{ d.char }}</button>
|
||||
</div>
|
||||
<div class="pet-pos">
|
||||
<label v-for="pos in positions" :key="pos" class="opt-radio opt-radio--sm" :class="{ active: petPosition === pos }">
|
||||
<input type="radio" :value="pos" v-model="petPosition" />
|
||||
<span>{{ posLabel(pos) }}</span>
|
||||
</label>
|
||||
</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>
|
||||
<button
|
||||
class="buy"
|
||||
:disabled="disabled"
|
||||
@click="onBuy"
|
||||
type="button"
|
||||
>{{ buyLabel }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { Product, PurchaseOptions } from '@/composables/useShop';
|
||||
|
||||
const props = defineProps<{
|
||||
product: Product;
|
||||
buying: boolean;
|
||||
owns: (kind: string) => boolean;
|
||||
petCount: number;
|
||||
freeMode: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ buy: [productId: string, options: PurchaseOptions] }>();
|
||||
|
||||
const meta = computed<any>(() => {
|
||||
try { return props.product.metaJson ? JSON.parse(props.product.metaJson) : {}; }
|
||||
catch { return {}; }
|
||||
});
|
||||
|
||||
// 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 positions = computed<string[]>(() => meta.value.positions ?? ['left', 'right', 'both']);
|
||||
const petDesign = ref<string>('');
|
||||
const petPosition = ref<'left' | 'right' | 'both'>('left');
|
||||
|
||||
const icon = computed(() => {
|
||||
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: any) => x.id === plan.value);
|
||||
if (p) price = p.price;
|
||||
}
|
||||
if (props.product.kind === 'ad-frame') {
|
||||
const d = durations.value.find((x: any) => x.days === durationDays.value);
|
||||
const f = formats.value.find((x: any) => 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');
|
||||
return false;
|
||||
});
|
||||
|
||||
const petFull = computed(() => props.product.kind === 'pet' && props.petCount >= 3);
|
||||
const soldOut = computed(() => props.product.stockLimit != null && props.product.stockSold >= props.product.stockLimit);
|
||||
|
||||
const disabled = computed(() => props.buying || ownedAlready.value || petFull.value || soldOut.value);
|
||||
|
||||
const buyLabel = computed(() => {
|
||||
if (props.buying) return '...';
|
||||
if (soldOut.value) return 'Épuisé';
|
||||
if (ownedAlready.value) return 'Possédé ✓';
|
||||
if (petFull.value) return 'Max 3 pets';
|
||||
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 posLabel(p: string): string {
|
||||
return p === 'left' ? 'Gauche' : p === 'right' ? 'Droite' : 'Les deux';
|
||||
}
|
||||
|
||||
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 = designs.value.find((x: any) => x.id === petDesign.value) ?? designs.value[0];
|
||||
if (d) { options.petDesign = d.id; options.petChar = d.char; }
|
||||
options.petPosition = petPosition.value;
|
||||
}
|
||||
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: 0 0 10px #ff226688;
|
||||
}
|
||||
|
||||
.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: #ffdd44; text-shadow: 0 0 8px #ffaa00cc; }
|
||||
.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: #ff44cc; box-shadow: 0 0 8px #ff44cc55; }
|
||||
.pet-pos { display: flex; gap: 6px; }
|
||||
|
||||
.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; 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: #ffdd66; font-family: 'Courier New', monospace; text-shadow: 0 0 10px #ffaa0044; }
|
||||
.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;
|
||||
box-shadow: 0 0 12px #00448855; transition: background 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.buy:hover:not(:disabled) { background: #005599; box-shadow: 0 0 18px #00ddff55; }
|
||||
.buy:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user