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:
@@ -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