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,112 @@
-- AlterTable
ALTER TABLE "messages" ADD COLUMN "richContent" TEXT,
ADD COLUMN "richMode" TEXT NOT NULL DEFAULT 'none';
-- CreateTable
CREATE TABLE "wallets" (
"ip" TEXT NOT NULL,
"balance" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "wallets_pkey" PRIMARY KEY ("ip")
);
-- CreateTable
CREATE TABLE "products" (
"id" TEXT NOT NULL,
"category" TEXT NOT NULL,
"name" TEXT NOT NULL,
"subtitle" TEXT,
"kind" TEXT NOT NULL,
"basePrice" INTEGER NOT NULL,
"promoPrice" INTEGER,
"badge" TEXT,
"stockLimit" INTEGER,
"stockSold" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"metaJson" TEXT,
CONSTRAINT "products_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "purchases" (
"id" TEXT NOT NULL,
"ip" TEXT NOT NULL,
"productId" TEXT,
"type" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"metaJson" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "purchases_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "entitlements" (
"id" TEXT NOT NULL,
"ip" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"expiresAt" TIMESTAMP(3),
"metaJson" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "entitlements_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ads" (
"id" TEXT NOT NULL,
"brand" TEXT NOT NULL,
"subtitle" TEXT,
"url" TEXT,
"cta" TEXT,
"icon" TEXT,
"tone" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"weight" INTEGER NOT NULL DEFAULT 1,
"active" BOOLEAN NOT NULL DEFAULT true,
"ownerIp" TEXT,
"format" TEXT,
"imageUrl" TEXT,
"expiresAt" TIMESTAMP(3),
"impressions" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ads_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "attachments" (
"id" TEXT NOT NULL,
"messageId" TEXT,
"ip" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"storagePath" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "attachments_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "purchases_ip_idx" ON "purchases"("ip");
-- CreateIndex
CREATE INDEX "entitlements_ip_idx" ON "entitlements"("ip");
-- CreateIndex
CREATE INDEX "entitlements_ip_kind_idx" ON "entitlements"("ip", "kind");
-- CreateIndex
CREATE INDEX "ads_kind_active_idx" ON "ads"("kind", "active");
-- CreateIndex
CREATE INDEX "attachments_messageId_idx" ON "attachments"("messageId");
-- AddForeignKey
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "messages"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -11,14 +11,120 @@ datasource db {
}
model Message {
id String @id @default(uuid())
content String @db.VarChar(267)
authorIp String
createdAt DateTime @default(now())
parentId String?
id String @id @default(uuid())
content String @db.VarChar(267)
authorIp String
createdAt DateTime @default(now())
parentId String?
// Rich-message tiers (paid): "none" | "htmlcss" | "js". richContent holds the raw
// markup/script, rendered ONLY inside a sandboxed iframe on the client.
richMode String @default("none")
richContent String? @db.Text
parent Message? @relation("ThreadReplies", fields: [parentId], references: [id])
replies Message[] @relation("ThreadReplies")
parent Message? @relation("ThreadReplies", fields: [parentId], references: [id])
replies Message[] @relation("ThreadReplies")
attachments Attachment[]
@@map("messages")
}
// ── Economy: fictional "crédits XIP", keyed on IP (no accounts) ──────────────
model Wallet {
ip String @id
balance Int @default(0) // centi-credits (9.99 "€" => 999)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("wallets")
}
// Seeded catalogue of purchasable features (faithful to the shop mockups).
model Product {
id String @id // slug: "cadre-pub","noads","style-dore","pet","bundle-cosmetic","rich-htmlcss","rich-js","no-file-limit","audio-alert"
category String // "publicite" | "abonnements" | "cosmetiques" | "promotions" | "premium"
name String
subtitle String?
kind String // "ad-frame" | "subscription" | "ip-skin" | "pet" | "bundle" | "rich" | "unlock" | "consumable"
basePrice Int // centi-credits
promoPrice Int?
badge String?
stockLimit Int? // e.g. 50 for style-dore; null = unlimited
stockSold Int @default(0)
active Boolean @default(true)
sortOrder Int @default(0)
metaJson String? @db.Text // options config (durations, formats, pet designs, plans…)
@@map("products")
}
// Append-only ledger: every credit movement (top-up, purchase, grant).
model Purchase {
id String @id @default(uuid())
ip String
productId String?
type String // "topup" | "purchase" | "grant"
amount Int // signed centi-credits: negative = spend, positive = grant
metaJson String? @db.Text
createdAt DateTime @default(now())
@@index([ip])
@@map("purchases")
}
// What an IP owns. Drives perks (skin/pets/noads), rich unlocks, ad frames, etc.
model Entitlement {
id String @id @default(uuid())
ip String
kind String // "noads" | "style-dore" | "pet" | "rich-htmlcss" | "rich-js" | "no-file-limit" | "ad-frame" | "audio-alert" | "element-skin"
active Boolean @default(true)
expiresAt DateTime? // subscriptions / ad-frame duration; null = permanent
metaJson String? @db.Text // pet: {design,position}; ad-frame: {format,url,days}; etc.
createdAt DateTime @default(now())
@@index([ip])
@@index([ip, kind])
@@map("entitlements")
}
// ── Real ad inventory (replaces the hardcoded AdBand / InlineCasinoAd) ───────
model Ad {
id String @id @default(uuid())
brand String
subtitle String?
url String?
cta String?
icon String?
tone String // "blue" | "green" | "purple" | "casino" | "user"
kind String // "band" | "casino"
weight Int @default(1)
active Boolean @default(true)
ownerIp String? // set when bought via "Cadre de Pub"
format String? // "static" | "gif"
imageUrl String?
expiresAt DateTime?
impressions Int @default(0)
createdAt DateTime @default(now())
@@index([kind, active])
@@map("ads")
}
// ── File attachments (free <=1 Mo; paid "no-file-limit" lifts the cap) ───────
model Attachment {
id String @id @default(uuid())
messageId String?
ip String
filename String
mimeType String
size Int
storagePath String
createdAt DateTime @default(now())
message Message? @relation(fields: [messageId], references: [id], onDelete: Cascade)
@@index([messageId])
@@map("attachments")
}

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()