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:
2026-05-30 22:47:23 +02:00
parent 97f6fdaeae
commit cf239ab95f
46 changed files with 4080 additions and 198 deletions

View 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>