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:
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user