Files
XIP/frontend/src/views/HomePage.vue
kerboul cf239ab95f 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>
2026-05-30 22:47:23 +02:00

309 lines
10 KiB
Vue

<template>
<div class="xip-app">
<!-- Bandeau de stats temps réel, toujours visible -->
<StatsTicker :stats="stats" :connected="connected" />
<div class="xip-root">
<!-- Bande pub gauche masquée si l'utilisateur a NoAds -->
<AdBand v-if="!myPerks.noads" />
<!-- Zone chat centrale -->
<div class="xip-center">
<ChatHeader :connected-count="stats?.connectedTabs ?? 0" />
<MessageList :messages="messages" :hide-ads="!!myPerks.noads" @reply="startReply" />
<!-- Bannière de réponse -->
<div v-if="replyingTo" class="reply-banner">
<span class="reply-banner-txt">
En réponse à <span class="reply-ip">{{ replyingTo.authorIp }}</span>
</span>
<button class="reply-cancel" @click="cancelReply" type="button">✕</button>
</div>
<!-- Composer riche (HTML/CSS ou JS) -->
<div v-if="richMode !== 'none'" class="rich-composer">
<div class="rich-head">
<span class="rich-badge" :class="`rich-badge--${richMode}`">
{{ richMode === 'js' ? ' JavaScript' : '🎨 HTML / CSS' }}
</span>
<button class="rich-close" @click="richMode = 'none'" type="button">✕ texte simple</button>
</div>
<textarea
v-model="richDraft"
class="rich-textarea"
:placeholder="richMode === 'js' ? '<script>document.body.style.background=&quot;lime&quot;<\/script>' : '<h1 style=&quot;color:#0ff&quot;>Salut</h1>'"
rows="4"
/>
</div>
<!-- Barre de saisie -->
<div class="input-bar">
<!-- Bouton mode riche (si débloqué) -->
<button
v-if="myPerks.richHtmlcss || myPerks.richJs"
class="icon-btn"
:title="richMenuTitle"
@click="cycleRichMode"
type="button"
>{{ richMode === 'none' ? '🎨' : richMode === 'htmlcss' ? '🎨' : '' }}</button>
<!-- Bouton pièce jointe -->
<button class="icon-btn" title="Joindre un fichier" @click="pickFile" type="button">📎</button>
<input ref="fileInput" type="file" hidden @change="onFileSelected" />
<!-- Bouton alerte audio (si débloqué) -->
<button
v-if="myPerks.audioAlert"
class="icon-btn icon-btn--alert"
:title="alertMsg || 'Déclencher l\'alerte audio générale'"
@click="triggerAlert"
type="button"
>🔊</button>
<div class="field-wrap">
<input
v-model="draft"
class="input-field"
type="text"
placeholder="Entrez un message..."
:maxlength="267"
@input="onInput"
@keydown.enter.exact.prevent="submit"
/>
<span class="char-counter" :class="{ warn: draft.length > 240 }">{{ draft.length }}/267</span>
</div>
<SendButton :disabled="!canSend || sending" @send="submit" />
</div>
<!-- Pièces jointes en attente -->
<div v-if="pendingFiles.length" class="pending-files">
<span v-for="f in pendingFiles" :key="f.id" class="pending-chip">
📎 {{ f.filename }} ({{ kb(f.size) }})
<button @click="removePending(f.id)" type="button">✕</button>
</span>
</div>
<div v-if="uploadError" class="upload-error">{{ uploadError }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import AdBand from '@/components/AdBand.vue';
import ChatHeader from '@/components/ChatHeader.vue';
import MessageList from '@/components/MessageList.vue';
import SendButton from '@/components/SendButton.vue';
import StatsTicker from '@/components/StatsTicker.vue';
import { useMessages } from '@/composables/useMessages';
import { useAttachments } from '@/composables/useAttachments';
import { useAlert } from '@/composables/useAlert';
const { messages, sending, postMessage, stats, connected, sendTyping, myPerks } = useMessages();
const { uploadFile, kb } = useAttachments();
const { fireAlert } = useAlert();
const draft = ref('');
// ── Alerte audio ──
const alertMsg = ref('');
async function triggerAlert(): Promise<void> {
const res = await fireAlert();
alertMsg.value = res.ok ? '' : res.error || '';
if (alertMsg.value) setTimeout(() => { alertMsg.value = ''; }, 3000);
}
// ── Réponse ──
const replyingTo = ref<{ id: string; authorIp: string } | null>(null);
function startReply(payload: { id: string; authorIp: string }): void {
replyingTo.value = payload;
}
function cancelReply(): void {
replyingTo.value = null;
}
// ── Mode riche ──
const richMode = ref<'none' | 'htmlcss' | 'js'>('none');
const richDraft = ref('');
const richMenuTitle = computed(() =>
myPerks.value.richJs ? 'Message riche : texte / HTML-CSS / JS' : 'Message riche : texte / HTML-CSS'
);
function cycleRichMode(): void {
// Cycle through the tiers the user owns.
if (richMode.value === 'none') richMode.value = myPerks.value.richHtmlcss ? 'htmlcss' : 'js';
else if (richMode.value === 'htmlcss') richMode.value = myPerks.value.richJs ? 'js' : 'none';
else richMode.value = 'none';
}
// ── Pièces jointes ──
const fileInput = ref<HTMLInputElement | null>(null);
const pendingFiles = ref<{ id: string; filename: string; size: number }[]>([]);
const uploadError = ref<string | null>(null);
function pickFile(): void {
uploadError.value = null;
fileInput.value?.click();
}
async function onFileSelected(e: Event): Promise<void> {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
const res = await uploadFile(file);
if (res.ok) {
pendingFiles.value.push({ id: res.attachment.id, filename: res.attachment.filename, size: res.attachment.size });
} else {
uploadError.value = res.error;
}
}
function removePending(id: string): void {
pendingFiles.value = pendingFiles.value.filter((f) => f.id !== id);
}
// ── Frappe (stats) ──
let prevLen = 0;
function onInput(): void {
const len = draft.value.length;
const delta = len - prevLen;
prevLen = len;
sendTyping(delta > 0 ? delta : 0);
}
// ── Envoi ──
const canSend = computed(() =>
!!draft.value.trim() || (richMode.value !== 'none' && !!richDraft.value.trim()) || pendingFiles.value.length > 0
);
async function submit(): Promise<void> {
if (!canSend.value) return;
const ok = await postMessage(draft.value, {
parentId: replyingTo.value?.id,
richMode: richMode.value !== 'none' && richDraft.value.trim() ? richMode.value : undefined,
richContent: richMode.value !== 'none' && richDraft.value.trim() ? richDraft.value : undefined,
attachmentIds: pendingFiles.value.map((f) => f.id),
});
if (ok) {
draft.value = '';
richDraft.value = '';
richMode.value = 'none';
pendingFiles.value = [];
replyingTo.value = null;
uploadError.value = null;
prevLen = 0;
}
}
</script>
<style scoped>
.xip-app {
display: flex;
flex-direction: column;
width: 100vw;
height: 100dvh;
background: #080808;
overflow: hidden;
}
.xip-root {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
.xip-center {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: #090910;
overflow: hidden;
}
/* ── Bannière de réponse ── */
.reply-banner {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
background: #0c1622;
border-top: 1px solid #16324a;
padding: 6px 20px;
}
.reply-banner-txt { font-family: Arial, sans-serif; font-size: 11px; color: #6688aa; }
.reply-ip { font-family: 'Courier New', monospace; color: #00ccff; font-weight: bold; }
.reply-cancel { background: none; border: none; color: #557; cursor: pointer; font-size: 13px; }
.reply-cancel:hover { color: #aac; }
/* ── Composer riche ── */
.rich-composer {
flex-shrink: 0;
background: #0c0c16;
border-top: 1px solid #1a1a26;
padding: 8px 20px;
}
.rich-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
.rich-badge { font-size: 11px; font-weight: bold; padding: 2px 8px; border-radius: 8px; }
.rich-badge--htmlcss { color: #00ddaa; background: #062019; }
.rich-badge--js { color: #ffcc44; background: #201a06; }
.rich-close { background: none; border: none; color: #557; cursor: pointer; font-size: 11px; }
.rich-close:hover { color: #aac; }
.rich-textarea {
width: 100%; box-sizing: border-box; resize: vertical;
background: #141420; border: 1px solid #222234; border-radius: 8px;
color: #aaccbb; font-family: 'Courier New', monospace; font-size: 12px; padding: 8px 10px; outline: none;
}
/* ── Barre de saisie ── */
.input-bar {
min-height: 70px;
flex-shrink: 0;
background: #0e0e16;
border-top: 1px solid #1a1a26;
display: flex;
align-items: center;
padding: 0 20px;
gap: 10px;
}
.icon-btn {
flex-shrink: 0;
width: 36px; height: 36px;
background: #141420; border: 1px solid #222234; border-radius: 50%;
font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.15s;
}
.icon-btn:hover { background: #1c1c2e; }
.icon-btn--alert { border-color: #aa3344; }
.icon-btn--alert:hover { background: #2a1418; box-shadow: 0 0 10px #ff224455; }
.field-wrap { flex: 1; position: relative; display: flex; align-items: center; }
.input-field {
flex: 1;
background: #141420;
border: 1px solid #222234;
border-radius: 23px;
padding: 12px 60px 12px 22px;
color: #aaaacc;
font-family: Arial, sans-serif;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
.input-field::placeholder { color: #2a2a44; }
.input-field:focus { border-color: #333355; }
.char-counter {
position: absolute; right: 16px;
font-family: 'Courier New', monospace; font-size: 10px; color: #33334d; pointer-events: none;
}
.char-counter.warn { color: #ff8844; }
/* ── Pièces jointes en attente ── */
.pending-files { flex-shrink: 0; display: flex; flex-wrap: wrap; gap: 8px; padding: 0 20px 10px; }
.pending-chip {
display: inline-flex; align-items: center; gap: 6px;
background: #141420; border: 1px solid #222234; border-radius: 12px;
padding: 4px 10px; font-size: 11px; color: #aaccbb; font-family: Arial, sans-serif;
}
.pending-chip button { background: none; border: none; color: #66f; cursor: pointer; }
.upload-error { flex-shrink: 0; padding: 0 20px 10px; color: #ff7788; font-size: 11px; font-family: Arial, sans-serif; }
</style>