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

@@ -2,36 +2,208 @@ import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
// ── Marketplace catalogue (faithful to the shop mockups) ────────────────────
// Prices are centi-credits (mockup € → credits): 9.99 → 999.
const PRODUCTS = [
{
id: "cadre-pub",
category: "publicite",
name: "Cadre de Pub",
subtitle: "1 000 impressions garanties · 130×180 px · lien cliquable",
kind: "ad-frame",
basePrice: 1500,
promoPrice: 999,
badge: "-33% FLASH PROMO",
sortOrder: 10,
metaJson: JSON.stringify({
durations: [
{ days: 7, extra: 0 },
{ days: 14, extra: 800 },
{ days: 30, extra: 2000 },
],
formats: [
{ id: "static", label: "Image statique", extra: 0 },
{ id: "gif", label: "GIF animé", extra: 300 },
],
}),
},
{
id: "noads",
category: "abonnements",
name: "Abonnement NoAds",
subtitle: "Supprime toutes les pubs du chat",
kind: "subscription",
basePrice: 499,
badge: "POPULAIRE",
sortOrder: 20,
metaJson: JSON.stringify({
plans: [
{ id: "monthly", label: "Mensuel", price: 499 },
{ id: "annual", label: "Annuel", price: 3999 },
],
}),
},
{
id: "style-dore",
category: "cosmetiques",
name: "Style Doré",
subtitle: "Ton IP en or brillant, visible de tous",
kind: "ip-skin",
basePrice: 999,
badge: "LIMITÉ 50 ex.",
stockLimit: 50,
sortOrder: 30,
metaJson: JSON.stringify({ variant: "gold" }),
},
{
id: "pet",
category: "cosmetiques",
name: "Pet de Nom",
subtitle: "Un petit élément décoratif autour de ton IP",
kind: "pet",
basePrice: 799,
badge: "NOUVEAU",
sortOrder: 40,
metaJson: JSON.stringify({
designs: [
{ id: "coeur", char: "♥" },
{ id: "etoile", char: "★" },
{ id: "diamant", char: "♦" },
{ id: "trefle", char: "♣" },
{ id: "couronne", char: "♚" },
{ id: "crane", char: "☠" },
{ id: "eclair", char: "⚡" },
{ id: "fleur", char: "✿" },
{ id: "note", char: "♫" },
{ id: "feu", char: "🔥" },
],
positions: ["left", "right", "both"],
}),
},
{
id: "bundle-cosmetic",
category: "promotions",
name: "Pack Cosmétique",
subtitle: "Style Doré + 1 Pet au choix",
kind: "bundle",
basePrice: 1798,
promoPrice: 1499,
badge: "-3 CR",
sortOrder: 50,
metaJson: JSON.stringify({ includes: ["style-dore", "pet"] }),
},
{
// id == entitlement kind, so the "unlock" branch grants "element-skin".
id: "element-skin",
category: "cosmetiques",
name: "Skin d'éléments",
subtitle: "Relooke ta barre de saisie et ton bouton d'envoi",
kind: "unlock",
basePrice: 599,
sortOrder: 45,
metaJson: JSON.stringify({}),
},
{
id: "rich-htmlcss",
category: "premium",
name: "Messages HTML / CSS",
subtitle: "Mets en forme tes messages (sans script)",
kind: "rich",
basePrice: 2999,
sortOrder: 60,
metaJson: JSON.stringify({}),
},
{
id: "rich-js",
category: "premium",
name: "Messages JavaScript",
subtitle: "Scripts interactifs (isolés). TRÈS cher.",
kind: "rich",
basePrice: 19999,
badge: "TRÈS TRÈS CHER",
sortOrder: 70,
metaJson: JSON.stringify({}),
},
{
id: "no-file-limit",
category: "premium",
name: "Fichiers illimités",
subtitle: "Plus de limite de 1 Mo sur tes pièces jointes",
kind: "unlock",
basePrice: 1499,
sortOrder: 80,
metaJson: JSON.stringify({}),
},
{
id: "audio-alert",
category: "premium",
name: "Alerte audio générale",
subtitle: "Fais hurler un son chez tout le monde (cooldown)",
kind: "consumable",
basePrice: 999,
badge: "CONSOMMABLE",
sortOrder: 90,
metaJson: JSON.stringify({ cooldownMs: 60000, maxDurationMs: 5000 }),
},
] as const;
// ── Ad inventory (the 4 hardcoded joke ads, now real data) ──────────────────
const ADS = [
{ brand: "NOVA", subtitle: "STORE 2026", url: "https://nova-store.io", cta: "DÉCOUVRIR", icon: "🛒", tone: "blue", kind: "band", weight: 1 },
{ brand: "APEX GEAR", subtitle: "Gaming Setup", url: "https://apex-gear.com", cta: "ACHETER", icon: "🎮", tone: "green", kind: "band", weight: 1 },
{ brand: "SHIELDVPN", subtitle: "Sécurité totale", url: "https://shieldvpn.net", cta: "ESSAI GRATUIT", icon: "🔒", tone: "purple", kind: "band", weight: 1 },
{ brand: "CASINO LUCKY", subtitle: "OFFRE EXCLUSIVE · +200% · 500€ max", url: "https://casino-lucky.bet", cta: "JOUER MAINTENANT", icon: "♠", tone: "casino", kind: "casino", weight: 1 },
] as const;
async function seedProducts() {
for (const p of PRODUCTS) {
await prisma.product.upsert({
where: { id: p.id },
create: p as any,
update: p as any,
});
}
console.log(`${PRODUCTS.length} produits upsertés.`);
}
async function seedAds() {
for (const a of ADS) {
// Idempotent on brand: only seed the canonical (non-user) ads once.
const existing = await prisma.ad.findFirst({ where: { brand: a.brand, ownerIp: null } });
if (existing) {
await prisma.ad.update({ where: { id: existing.id }, data: a as any });
} else {
await prisma.ad.create({ data: a as any });
}
}
console.log(`${ADS.length} pubs upsertées.`);
}
async function seedMessages() {
const count = await prisma.message.count();
if (count > 0) {
console.log("⏭️ Database already seeded, skipping.");
console.log("⏭️ Messages déjà présents, seed messages ignoré.");
return;
}
const root1 = await prisma.message.create({
data: {
content: "Bienvenue sur XIP — le réseau social sans filtre ni compte.",
authorIp: "1.2.3.4",
},
});
await prisma.message.create({
data: {
content: "Pas de compte, ton IP c'est toi.",
authorIp: "5.6.7.8",
},
data: { content: "Pas de compte, ton IP c'est toi.", authorIp: "5.6.7.8" },
});
await prisma.message.create({
data: {
content: "Réponse au premier message !",
authorIp: "9.10.11.12",
parentId: root1.id,
},
data: { content: "Réponse au premier message !", authorIp: "9.10.11.12", parentId: root1.id },
});
console.log("✅ 3 messages de démo créés.");
}
console.log("✅ Database seeded with 3 messages.");
async function main() {
await seedProducts();
await seedAds();
await seedMessages();
}
main()