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

@@ -1,17 +1,28 @@
<!-- Un message avec ses éventuelles réponses -->
<!-- Un message avec ses éventuelles réponses, perks d'auteur, rich content et pièces jointes -->
<template>
<div class="message-item">
<!-- Auteur + horodatage -->
<div class="message-meta">
<span
class="ip"
:style="{ color: color, textShadow: glow }"
>{{ message.authorIp }}</span>
<span class="ip-wrap">
<span v-if="petsLeft(message)" class="pet">{{ petsLeft(message) }}</span>
<span class="ip" :style="ipStyle(message)">{{ message.authorIp }}</span>
<span v-if="petsRight(message)" class="pet">{{ petsRight(message) }}</span>
<span v-if="message.authorPerks?.badge" class="vip-badge">VIP</span>
</span>
<span class="ts">{{ fmt(message.createdAt) }}</span>
<button class="reply-btn" @click="$emit('reply', { id: message.id, authorIp: message.authorIp })" type="button">↩ répondre</button>
</div>
<!-- Contenu -->
<p class="message-body">{{ message.content }}</p>
<!-- Contenu : riche (iframe sandbox) ou texte simple -->
<RichContent
v-if="message.richMode && message.richMode !== 'none' && message.richContent"
:mode="message.richMode"
:content="message.richContent"
/>
<p v-else class="message-body">{{ message.content }}</p>
<!-- Pièces jointes -->
<MessageAttachments v-if="message.attachments?.length" :attachments="message.attachments" />
<!-- Réponses -->
<div
@@ -19,12 +30,20 @@
:key="reply.id"
class="reply"
>
<span
class="ip reply-ip"
:style="{ color: getColor(reply.authorIp) }"
>{{ reply.authorIp }}</span>
<span class="ip-wrap">
<span v-if="petsLeft(reply)" class="pet pet--sm">{{ petsLeft(reply) }}</span>
<span class="ip reply-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span>
<span v-if="petsRight(reply)" class="pet pet--sm">{{ petsRight(reply) }}</span>
</span>
<span class="ts">{{ fmt(reply.createdAt) }}</span>
<p class="message-body reply-body">{{ reply.content }}</p>
<button class="reply-btn" @click="$emit('reply', { id: message.id, authorIp: reply.authorIp })" type="button">↩</button>
<RichContent
v-if="reply.richMode && reply.richMode !== 'none' && reply.richContent"
:mode="reply.richMode"
:content="reply.richContent"
/>
<p v-else class="message-body reply-body">{{ reply.content }}</p>
<MessageAttachments v-if="reply.attachments?.length" :attachments="reply.attachments" />
</div>
<div class="divider" />
@@ -32,22 +51,47 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { Message } from '@/composables/useMessages';
import { getIpColor, getIpGlow } from '@/composables/ipColor';
import type { Message, Reply } from '@/composables/useMessages';
import { getIpColorWithPerks, getIpGlowWithPerks } from '@/composables/ipColor';
import { usePerks } from '@/composables/usePerks';
import RichContent from './RichContent.vue';
import MessageAttachments from './MessageAttachments.vue';
const props = defineProps<{ message: Message }>();
defineProps<{ message: Message }>();
defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
const color = computed(() => getIpColor(props.message.authorIp));
const glow = computed(() => getIpGlow(color.value));
const { perksFor } = usePerks();
function getColor(ip: string) { return getIpColor(ip); }
/** Perks for an author: prefer the perks embedded in the payload, else the store. */
function perksOf(m: Reply): any {
return m.authorPerks ?? perksFor(m.authorIp);
}
function ipStyle(m: Reply) {
const p = perksOf(m);
return {
color: getIpColorWithPerks(m.authorIp, p),
textShadow: getIpGlowWithPerks(m.authorIp, p),
};
}
function petsLeft(m: Reply): string {
const pets = perksOf(m)?.pets ?? [];
return pets
.filter((x: any) => x.position === 'left' || x.position === 'both')
.map((x: any) => x.char)
.join('');
}
function petsRight(m: Reply): string {
const pets = perksOf(m)?.pets ?? [];
return pets
.filter((x: any) => x.position === 'right' || x.position === 'both')
.map((x: any) => x.char)
.join('');
}
function fmt(date: string): string {
return new Date(date).toLocaleTimeString('fr-FR', {
hour: '2-digit',
minute: '2-digit',
});
return new Date(date).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
}
</script>
@@ -63,6 +107,15 @@ function fmt(date: string): string {
padding: 0 25px;
}
.ip-wrap { display: inline-flex; align-items: baseline; gap: 4px; }
.pet { font-size: 12px; filter: drop-shadow(0 0 3px currentColor); }
.pet--sm { font-size: 11px; }
.vip-badge {
font-family: Arial, sans-serif; font-size: 8px; font-weight: bold;
color: #ffcc44; background: #2a2206; border: 1px solid #665511; border-radius: 4px;
padding: 0 4px; margin-left: 4px; letter-spacing: 0.5px;
}
.ip {
font-family: 'Courier New', monospace;
font-size: 12px;
@@ -75,12 +128,22 @@ function fmt(date: string): string {
color: #303030;
}
.reply-btn {
background: none; border: none; cursor: pointer;
font-family: Arial, sans-serif; font-size: 10px; color: #33335a;
padding: 0; opacity: 0; transition: opacity 0.12s, color 0.12s;
}
.message-item:hover .reply-btn,
.reply:hover .reply-btn { opacity: 1; }
.reply-btn:hover { color: #00ccff; }
.message-body {
font-family: Arial, sans-serif;
font-size: 13px;
color: #c0c0c0;
padding: 3px 25px 0;
margin: 0;
word-break: break-word;
}
.divider {