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,220 @@
<!-- Bandeau de stats permanent façon téléscripteur néon (casino / bourse). -->
<template>
<div class="ticker" :class="{ 'is-off': !connected }">
<!-- Badge LIVE fixe à gauche -->
<div class="ticker-badge">
<span class="ticker-dot" />
<span class="ticker-badge-txt">{{ connected ? 'LIVE' : '···' }}</span>
</div>
<!-- Piste défilante (2 groupes identiques pour une boucle sans couture) -->
<div class="ticker-viewport">
<div class="ticker-track">
<div
v-for="copy in 2"
:key="copy"
class="ticker-group"
:aria-hidden="copy === 2 ? 'true' : undefined"
>
<span
v-for="item in items"
:key="item.key + '-' + copy"
class="chip"
:class="`chip--${item.tone}`"
>
<span class="chip-val">
<AnimatedNumber :value="item.value" :decimals="item.decimals ?? 0" />
<span v-if="item.unit" class="chip-unit">{{ item.unit }}</span>
</span>
<span class="chip-label">{{ item.label }}</span>
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import AnimatedNumber from './AnimatedNumber.vue';
import type { Stats } from '@/composables/useRealtime';
const props = defineProps<{ stats: Stats | null; connected: boolean }>();
type Tone = 'cyan' | 'green' | 'magenta' | 'orange' | 'plain';
interface Chip {
key: string;
label: string;
value: number;
tone: Tone;
unit?: string;
decimals?: number;
}
const ZERO: Stats = {
connectedTabs: 0,
typingNow: 0,
lettersPerSec: 0,
msgsPerMin: 0,
messages: 0,
replies: 0,
charsSent: 0,
lettersTyped: 0,
uniqueIps: 0,
longestMsg: 0,
abandonRate: 0,
avgLength: 0,
moneyExtorted: 0,
};
const items = computed<Chip[]>(() => {
const s = props.stats ?? ZERO;
return [
{ key: 'tabs', label: 'onglets connectés', value: s.connectedTabs, tone: 'cyan' },
{ key: 'typing', label: 'écrivent là', value: s.typingNow, tone: 'green' },
{ key: 'lps', label: 'lettres / s', value: s.lettersPerSec, decimals: 1, tone: 'green' },
{ key: 'mpm', label: 'messages / min', value: s.msgsPerMin, tone: 'green' },
{ key: 'msgs', label: 'messages', value: s.messages, tone: 'cyan' },
{ key: 'replies', label: 'réponses', value: s.replies, tone: 'plain' },
{ key: 'chars', label: 'caractères envoyés', value: s.charsSent, tone: 'plain' },
{ key: 'letters', label: 'lettres tapées', value: s.lettersTyped, tone: 'magenta' },
{ key: 'ips', label: 'IP uniques', value: s.uniqueIps, tone: 'cyan' },
{ key: 'longest', label: 'le + long', value: s.longestMsg, unit: ' car', tone: 'plain' },
{ key: 'abandon', label: "taux d'abandon", value: s.abandonRate, decimals: 1, unit: ' %', tone: 'orange' },
{ key: 'avg', label: 'longueur moy.', value: s.avgLength, decimals: 1, unit: ' car', tone: 'plain' },
{ key: 'money', label: 'argent extorqué', value: s.moneyExtorted, decimals: 2, unit: ' €', tone: 'orange' },
];
});
</script>
<style scoped>
.ticker {
position: relative;
flex-shrink: 0;
height: 40px;
display: flex;
align-items: stretch;
background: #0a0a12;
border-bottom: 1px solid #00eeff33;
box-shadow: inset 0 -1px 0 #00eeff14, 0 2px 14px #00000066;
overflow: hidden;
}
/* ── Badge LIVE fixe ── */
.ticker-badge {
position: relative;
z-index: 2;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 7px;
padding: 0 14px;
background: #0e0e18;
border-right: 1px solid #00eeff33;
box-shadow: 6px 0 12px #0a0a12;
}
.ticker-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #00ff88;
box-shadow: 0 0 8px #00ff66;
animation: blink 1.5s ease-in-out infinite;
}
.ticker-badge-txt {
font-family: 'Courier New', monospace;
font-size: 11px;
font-weight: bold;
letter-spacing: 2px;
color: #00ff88;
text-shadow: 0 0 8px #00ff4466;
}
.ticker.is-off .ticker-dot {
background: #ff3344;
box-shadow: 0 0 8px #ff2233;
animation: none;
}
.ticker.is-off .ticker-badge-txt {
color: #ff5566;
text-shadow: none;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* ── Piste défilante ── */
.ticker-viewport {
flex: 1;
min-width: 0;
overflow: hidden;
display: flex;
align-items: center;
}
.ticker-track {
display: inline-flex;
white-space: nowrap;
will-change: transform;
animation: ticker-scroll 48s linear infinite;
}
.ticker:hover .ticker-track {
animation-play-state: paused;
}
.ticker-group {
display: inline-flex;
align-items: center;
}
@keyframes ticker-scroll {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
/* ── Chips ── */
.chip {
position: relative;
display: inline-flex;
align-items: baseline;
gap: 7px;
padding: 0 22px;
}
.chip::after {
content: '';
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
height: 16px;
width: 1px;
background: #ffffff14;
}
.chip-val {
font-family: 'Courier New', monospace;
font-size: 15px;
font-weight: bold;
color: #d8d8e8;
}
.chip-unit {
font-size: 10px;
font-weight: normal;
opacity: 0.6;
}
.chip-label {
font-family: Arial, sans-serif;
font-size: 10px;
letter-spacing: 0.5px;
text-transform: uppercase;
color: #50506e;
}
.chip--cyan .chip-val { color: #00eeff; text-shadow: 0 0 9px #00ccff55; }
.chip--green .chip-val { color: #33ff77; text-shadow: 0 0 9px #00ff4455; }
.chip--magenta .chip-val { color: #ff44cc; text-shadow: 0 0 9px #ff22aa55; }
.chip--orange .chip-val { color: #ffaa44; text-shadow: 0 0 9px #ff880055; }
/* Accessibilité : pas de défilement si l'utilisateur le refuse */
@media (prefers-reduced-motion: reduce) {
.ticker-track { animation: none; }
.ticker-viewport { overflow-x: auto; scrollbar-width: none; }
.ticker-viewport::-webkit-scrollbar { display: none; }
}
</style>