feat: conformite enonce - explorer, favoris, stats perso, tests, slots
Some checks failed
Deploy XIP / deploy (push) Failing after 37s

Fonctionnel
- Backend messages : GET /api/messages/:id (detail) + recherche (q),
  pagination par curseur (before/limit) avec enveloppe { items, nextCursor,
  hasMore } ; le flux temps reel garde l'ancien format quand aucun parametre.
- Explorer (/explorer) : catalogue distant, recherche debouncee + annulable
  (AbortController), filtre, defilement infini, etat garde (keep-alive).
- Details par id : /message/:id et /shop/p/:id (consomment route.params).
- Favoris (/favoris) : liste perso persistee en localStorage, notation
  (note/rating/statut) via modale, refletee partout (bouton favori).
- Mes stats (/mes-stats) : agregats derives des favoris (note moyenne, top
  pays/auteurs, statuts), auto-mis a jour, route gardee si liste vide.
- Routeur : pages secondaires en lazy-load + repli, garde beforeEnter.

Technique
- Slots : PrefSection (slot defaut + slot nomme) enveloppe les 5 sections
  "Mes Persos" ; Modal (Teleport + slots).
- v-model custom : SearchBox (defineModel + debounce).
- Directive custom : v-click-outside.
- Tests Vitest : 25 tests (etat, fonctions, composants), ~86% du code metier.
- Retrait d'Ionic (inutilise). Script typecheck backend ; tsconfig @types/bun.
- Correctif type : garde stockLimit nullable dans l'achat (catalog.ts).
- README complet (URL, stack, run, tests, secrets, deploiement, mention IA).
This commit is contained in:
2026-05-31 23:57:00 +02:00
committed by kerboul
parent 9dd72b9b2d
commit cfa2eadec9
111 changed files with 9634 additions and 7875 deletions

View File

@@ -1,282 +1,282 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
// ── 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 }),
},
// ── Cosmetics: IP color + send button skins ──────────────────────────────
{
id: "ip-colors",
category: "cosmetiques",
name: "Palette IP",
subtitle: "Personnalise la couleur de ton adresse IP dans le chat",
kind: "unlock",
basePrice: 299,
sortOrder: 46,
metaJson: JSON.stringify({}),
},
{
id: "send-skin-honker",
category: "cosmetiques",
name: "Doigt d'honneur",
subtitle: "Bouton d'envoi qui exprime tout",
kind: "send-skin",
basePrice: 149,
sortOrder: 47,
metaJson: JSON.stringify({ char: "🖕", label: "Doigt d'honneur" }),
},
{
id: "send-skin-skull",
category: "cosmetiques",
name: "Crâne",
subtitle: "Envoyer avec style... macabre",
kind: "send-skin",
basePrice: 149,
sortOrder: 48,
metaJson: JSON.stringify({ char: "💀", label: "Crâne" }),
},
{
id: "send-skin-rocket",
category: "cosmetiques",
name: "Fusée",
subtitle: "Tes messages décollent",
kind: "send-skin",
basePrice: 149,
sortOrder: 49,
metaJson: JSON.stringify({ char: "🚀", label: "Fusée" }),
},
{
id: "send-skin-ghost",
category: "cosmetiques",
name: "Fantôme",
subtitle: "Boo !",
kind: "send-skin",
basePrice: 149,
sortOrder: 50,
metaJson: JSON.stringify({ char: "👻", label: "Fantôme" }),
},
{
id: "send-skin-bomb",
category: "cosmetiques",
name: "Bombe",
subtitle: "Message explosif",
kind: "send-skin",
basePrice: 149,
sortOrder: 51,
metaJson: JSON.stringify({ char: "💣", label: "Bombe" }),
},
{
id: "send-skin-sword",
category: "cosmetiques",
name: "Épée",
subtitle: "Tranche le silence",
kind: "send-skin",
basePrice: 149,
sortOrder: 52,
metaJson: JSON.stringify({ char: "⚔️", label: "Épée" }),
},
] 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("⏭️ 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" },
});
await prisma.message.create({
data: { content: "Réponse au premier message !", authorIp: "9.10.11.12", parentId: root1.id },
});
console.log("✅ 3 messages de démo créés.");
}
async function main() {
await seedProducts();
await seedAds();
await seedMessages();
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
// ── 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 }),
},
// ── Cosmetics: IP color + send button skins ──────────────────────────────
{
id: "ip-colors",
category: "cosmetiques",
name: "Palette IP",
subtitle: "Personnalise la couleur de ton adresse IP dans le chat",
kind: "unlock",
basePrice: 299,
sortOrder: 46,
metaJson: JSON.stringify({}),
},
{
id: "send-skin-honker",
category: "cosmetiques",
name: "Doigt d'honneur",
subtitle: "Bouton d'envoi qui exprime tout",
kind: "send-skin",
basePrice: 149,
sortOrder: 47,
metaJson: JSON.stringify({ char: "🖕", label: "Doigt d'honneur" }),
},
{
id: "send-skin-skull",
category: "cosmetiques",
name: "Crâne",
subtitle: "Envoyer avec style... macabre",
kind: "send-skin",
basePrice: 149,
sortOrder: 48,
metaJson: JSON.stringify({ char: "💀", label: "Crâne" }),
},
{
id: "send-skin-rocket",
category: "cosmetiques",
name: "Fusée",
subtitle: "Tes messages décollent",
kind: "send-skin",
basePrice: 149,
sortOrder: 49,
metaJson: JSON.stringify({ char: "🚀", label: "Fusée" }),
},
{
id: "send-skin-ghost",
category: "cosmetiques",
name: "Fantôme",
subtitle: "Boo !",
kind: "send-skin",
basePrice: 149,
sortOrder: 50,
metaJson: JSON.stringify({ char: "👻", label: "Fantôme" }),
},
{
id: "send-skin-bomb",
category: "cosmetiques",
name: "Bombe",
subtitle: "Message explosif",
kind: "send-skin",
basePrice: 149,
sortOrder: 51,
metaJson: JSON.stringify({ char: "💣", label: "Bombe" }),
},
{
id: "send-skin-sword",
category: "cosmetiques",
name: "Épée",
subtitle: "Tranche le silence",
kind: "send-skin",
basePrice: 149,
sortOrder: 52,
metaJson: JSON.stringify({ char: "⚔️", label: "Épée" }),
},
] 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("⏭️ 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" },
});
await prisma.message.create({
data: { content: "Réponse au premier message !", authorIp: "9.10.11.12", parentId: root1.id },
});
console.log("✅ 3 messages de démo créés.");
}
async function main() {
await seedProducts();
await seedAds();
await seedMessages();
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());