feat: thème WhatsApp + fix envoi rich/compact + nav shop + refactor
All checks were successful
Deploy XIP / deploy (push) Successful in 43s

Theming
- Thème global piloté par variables CSS (:root + [data-theme]) appliqué via un
  attribut data-theme sur la racine app. Ajout du thème "WhatsApp" (bulles +
  palette verte, bulle sortante #005c4b) sans nouveau composant message.
- useTheme: type Theme étendu + THEME_LAYOUT (whatsapp = layout bulles).
- MessageList: sélection du composant par layout avec garde de repli
  (fini le <component :is="undefined">).
- Fix du thème "compact" cassé : nouveau MessageItemCompact.vue (variante dense).
- Surfaces migrées en variables : fond app/chat, header, bouton d'envoi, bulles.

Corrections
- Bug envoi rich/fichier : le backend exigeait un content texte non vide même
  en mode HTML/CSS/JS. Validation par présence (texte OU rich OU piece jointe) ;
  le front n'envoie plus d'espace bidon. Plus besoin de faux texte.
- Shop : suppression de "Tout voir", navigation forcee par categorie
  (defaut: Publicite).

Refactor (lisibilite)
- Parite perks backend (ip-colors, audio-alert, send-skin-*) ; /api/shop/me
  renvoie myPerks precalcule ; le front consomme directement (suppression de la
  derivation dupliquee + nettoyage d'un artefact de merge dans useMessages).
- Coherence composable-singleton : myPerks lu via useMyPerks() partout.
- Extraction du composer de HomePage vers ChatComposer.vue (HomePage = layout).
- Helper type parseMeta<T>() pour les metaJson (moins de any).
- vue-tsc --noEmit : 0 erreur.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 19:51:24 +02:00
parent c0b82222bd
commit aca608e520
17 changed files with 524 additions and 303 deletions

View File

@@ -25,6 +25,9 @@ export interface Perks {
richHtmlcss?: boolean;
richJs?: boolean;
noFileLimit?: boolean;
ipColors?: boolean;
audioAlert?: boolean;
sendSkins?: { id: string; char: string; label?: string }[];
}
const perksKey = (ip: string) => `xip:perks:${ip}`;
@@ -88,6 +91,22 @@ export async function getPerksForIp(ip: string): Promise<Perks> {
case "no-file-limit":
perks.noFileLimit = true;
break;
case "ip-colors":
perks.ipColors = true;
break;
case "audio-alert":
perks.audioAlert = true;
break;
}
// Send-button skins use a prefixed kind (send-skin-rocket, …), so they
// can't be matched by the switch above.
if (e.kind.startsWith("send-skin-")) {
(perks.sendSkins ??= []).push({
id: e.kind,
char: meta.char ?? "?",
label: meta.label,
});
}
}
if (pets.length) perks.pets = pets.slice(0, 3);

View File

@@ -66,17 +66,25 @@ messages.post("/", async (c) => {
const ip = getClientIp(c);
const body = await c.req.json<{
content: string;
content?: string;
parentId?: string;
richMode?: "htmlcss" | "js";
richContent?: string;
attachmentIds?: string[];
}>();
if (!body.content || body.content.trim().length === 0) {
return c.json({ error: "Content is required" }, 400);
// A message is valid if it has ANY of: plain text, rich content, or attachments.
// (Rich-only and file-only messages are legitimate — no need for placeholder text.)
const hasContent = typeof body.content === "string" && body.content.trim().length > 0;
const hasRich =
!!body.richMode && !!body.richContent && body.richContent.trim().length > 0;
const hasAttachments =
Array.isArray(body.attachmentIds) && body.attachmentIds.length > 0;
if (!hasContent && !hasRich && !hasAttachments) {
return c.json({ error: "Message vide" }, 400);
}
if (body.content.length > 267) {
if (hasContent && body.content!.trim().length > 267) {
return c.json({ error: "Content exceeds 267 characters" }, 400);
}
@@ -97,7 +105,7 @@ messages.post("/", async (c) => {
richContent = body.richContent;
}
const content = body.content.trim();
const content = (body.content ?? "").trim();
const parentId = body.parentId ?? null;
const message = await prisma.message.create({

View File

@@ -11,6 +11,7 @@ import {
type PurchaseOptions,
} from "../lib/catalog";
import { broadcast, broadcastToIp } from "../realtime";
import { getPerksForIp } from "../lib/perks";
const shop = new Hono();
@@ -30,11 +31,12 @@ shop.get("/products/:id", async (c) => {
// GET /api/shop/me — my balance + owned entitlements
shop.get("/me", async (c) => {
const ip = getClientIp(c);
const [wallet, entitlements] = await Promise.all([
const [wallet, entitlements, myPerks] = await Promise.all([
getWallet(ip),
getEntitlements(ip),
getPerksForIp(ip),
]);
return c.json({ wallet, entitlements });
return c.json({ wallet, entitlements, myPerks });
});
// POST /api/shop/purchase { productId, options }

View File

@@ -0,0 +1,243 @@
<!--
Barre de composition : texte simple, éditeur riche (HTML/CSS · JS), pièces
jointes, alerte audio, bouton d'envoi. Possède son propre état ; lit les
composables partagés directement (pas de prop-drilling). La réponse en cours
est passée par prop `replyingTo` ; on émet `clear-reply` une fois le message parti.
-->
<template>
<div class="composer">
<!-- Éditeur 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 === 'js' ? '' : '🎨' }}</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 v-show="richMode === 'none'" 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>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import SendButton from './SendButton.vue';
import { useMessages, useMyPerks } from '@/composables/useMessages';
import { useAttachments } from '@/composables/useAttachments';
import { useAlert } from '@/composables/useAlert';
const props = defineProps<{ replyingTo: { id: string; authorIp: string } | null }>();
const emit = defineEmits<{ 'clear-reply': [] }>();
const { sending, postMessage, sendTyping } = useMessages();
const { myPerks } = useMyPerks();
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);
}
// ── 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: props.replyingTo?.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 = [];
uploadError.value = null;
prevLen = 0;
emit('clear-reply');
}
}
</script>
<style scoped>
/* ── Éditeur 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: #1e1218; }
.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: 8px 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>

View File

@@ -37,8 +37,8 @@ const { theme } = useTheme();
.chat-header {
height: 52px;
flex-shrink: 0;
background: #0e0e16;
border-bottom: 1px solid #1a1a2a;
background: var(--xip-header-bg);
border-bottom: 1px solid var(--xip-header-border);
display: flex;
align-items: center;
justify-content: space-between;

View File

@@ -103,22 +103,22 @@ const isMine = computed(() => props.message.authorIp === props.myIp);
}
.bubble {
background: #141422;
border: 1px solid #222236;
background: var(--xip-bubble-other);
border: 1px solid var(--xip-bubble-other-border);
border-radius: 14px 14px 14px 4px;
padding: 7px 13px;
font-family: Arial, sans-serif;
font-size: 13px;
color: #c0c0c0;
color: #e0e0e8;
max-width: 72%;
word-break: break-word;
line-height: 1.4;
}
.bubble--mine {
background: #0e1f30;
border-color: #1a3a55;
background: var(--xip-bubble-sent);
border-color: var(--xip-bubble-sent-border);
border-radius: 14px 14px 4px 14px;
color: #cce0f0;
color: #eef4f0;
}
.bubble-thread {

View File

@@ -0,0 +1,95 @@
<!-- Variante "compact" du message une ligne dense (IP + contenu inline) -->
<template>
<div class="compact-item">
<div class="compact-line">
<span class="compact-ip" :style="ipStyle(message)">
<span v-if="petsLeft(message)" class="pet">{{ petsLeft(message) }}</span>{{ message.authorIp }}<span v-if="petsRight(message)" class="pet">{{ petsRight(message) }}</span>
</span>
<img
v-if="message.authorGeo?.countryCode"
:src="`https://flagcdn.com/16x12/${message.authorGeo.countryCode.toLowerCase()}.png`"
:alt="message.authorGeo.countryCode"
class="compact-flag"
/>
<RichContent
v-if="message.richMode && message.richMode !== 'none' && message.richContent"
:mode="message.richMode"
:content="message.richContent"
/>
<span v-else class="compact-body">{{ message.content }}</span>
<span class="compact-ts">{{ fmt(message.createdAt) }}</span>
<button
class="compact-reply-btn"
type="button"
@click="$emit('reply', { id: message.id, authorIp: message.authorIp })"
></button>
</div>
<MessageAttachments v-if="message.attachments?.length" :attachments="message.attachments" />
<!-- Réponses, inline et indentées -->
<div
v-for="reply in message.replies"
:key="reply.id"
class="compact-line compact-line--reply"
>
<span class="compact-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span>
<RichContent
v-if="reply.richMode && reply.richMode !== 'none' && reply.richContent"
:mode="reply.richMode"
:content="reply.richContent"
/>
<span v-else class="compact-body">{{ reply.content }}</span>
<span class="compact-ts">{{ fmt(reply.createdAt) }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import type { Message } from '@/composables/useMessages';
import { useMessageItem } from '@/composables/useMessageItem';
import RichContent from './RichContent.vue';
import MessageAttachments from './MessageAttachments.vue';
defineProps<{ message: Message; myIp?: string }>();
defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
const { ipStyle, petsLeft, petsRight, fmt } = useMessageItem();
</script>
<style scoped>
.compact-item {
padding: 1px 14px;
border-bottom: 1px solid #0e0e18;
}
.compact-line {
display: flex;
align-items: baseline;
gap: 8px;
font-family: 'Courier New', monospace;
font-size: 12px;
line-height: 1.7;
}
.compact-line--reply { padding-left: 24px; opacity: 0.85; }
.compact-ip { font-weight: bold; flex-shrink: 0; }
.pet { font-size: 11px; }
.compact-flag { width: 14px; height: 10px; object-fit: cover; border-radius: 2px; flex-shrink: 0; }
.compact-body {
font-family: 'Lato', Arial, sans-serif;
color: #c0c0c0;
flex: 1;
min-width: 0;
word-break: break-word;
}
.compact-ts { color: #303040; font-size: 10px; flex-shrink: 0; }
.compact-reply-btn {
background: none; border: none; cursor: pointer;
font-size: 11px; color: #33335a; padding: 0; flex-shrink: 0;
opacity: 0; transition: opacity 0.12s;
}
.compact-item:hover .compact-reply-btn { opacity: 1; }
.compact-reply-btn:hover { color: var(--xip-accent); }
</style>

View File

@@ -25,9 +25,10 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import type { Message } from '@/composables/useMessages';
import { useTheme } from '@/composables/useTheme';
import { useTheme, THEME_LAYOUT, type Layout } from '@/composables/useTheme';
import MessageItem from './MessageItem.vue';
import MessageItemBubble from './MessageItemBubble.vue';
import MessageItemCompact from './MessageItemCompact.vue';
import InlineCasinoAd from './InlineCasinoAd.vue';
const props = defineProps<{ messages: Message[]; hideAds?: boolean; myIp?: string }>();
@@ -35,10 +36,16 @@ defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
const { theme } = useTheme();
const messageComponent = computed(() => {
if (theme.value === 'bubble') return MessageItemBubble;
return MessageItem;
});
// One component per layout family. The `?? MessageItem` fallback guarantees a
// missing/unknown layout can never produce `<component :is="undefined">`.
const LAYOUT_COMPONENT: Record<Layout, typeof MessageItem> = {
classic: MessageItem,
bubble: MessageItemBubble,
compact: MessageItemCompact,
};
const messageComponent = computed(
() => LAYOUT_COMPONENT[THEME_LAYOUT[theme.value]] ?? MessageItem,
);
const listEl = ref<HTMLElement | null>(null);

View File

@@ -35,6 +35,9 @@ const activeSkinChar = computed(() => {
});
const btnStyle = computed(() => {
// On the default preset, defer to the theme's CSS variables (so e.g. the
// WhatsApp theme tints the button green). A chosen preset overrides the theme.
if (prefs.sendButton === 'default') return {};
const p = SEND_BUTTON_PRESETS[prefs.sendButton];
return { background: p.bg, color: p.color, borderRadius: p.radius };
});
@@ -77,6 +80,10 @@ function onRightClick(e: MouseEvent): void {
height: 42px;
flex-shrink: 0;
border: 1px solid #ffffff10;
border-radius: 50%;
/* Defaults from the theme palette; a chosen preset overrides via inline style. */
background: var(--xip-send-bg);
color: var(--xip-send-fg);
cursor: pointer;
display: flex;
align-items: center;

View File

@@ -10,7 +10,8 @@ export function getIpColor(ip: string): string {
return PALETTE[Math.abs(hash) % PALETTE.length];
}
export function getIpGlow(color: string): string {
// Glows are currently disabled globally; params kept for signature stability.
export function getIpGlow(_color: string): string {
return 'none';
}
@@ -27,6 +28,6 @@ export function getIpColorWithPerks(ip: string, perks?: PerkLike | null): string
return getIpColor(ip);
}
export function getIpGlowWithPerks(ip: string, perks?: PerkLike | null): string {
export function getIpGlowWithPerks(_ip: string, _perks?: PerkLike | null): string {
return 'none';
}

View File

@@ -48,37 +48,19 @@ export interface Message extends Reply {
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
/** Refresh the viewer's own perks from the server (callable from anywhere). */
/**
* Refresh the viewer's own perks from the server (callable from anywhere).
* The backend computes the perks (entitlement.kind → Perks) and returns them
* precomputed as `myPerks`, so we just adopt them — no client-side re-derivation.
*/
export async function refreshMyPerks(): Promise<void> {
try {
const res = await fetch(`${API_URL}/api/shop/me`);
if (!res.ok) return;
const { entitlements } = (await res.json()) as {
entitlements: { kind: string; metaJson?: string | null }[];
};
const p: Perks = {};
const pets: { char: string; position: 'left' | 'right' | 'both' }[] = [];
for (const e of entitlements) {
let meta: any = {};
try { meta = e.metaJson ? JSON.parse(e.metaJson) : {}; } catch { /* */ }
if (e.kind === 'noads') { p.noads = true; if (meta.plan === 'annual') p.badge = true; }
if (e.kind === 'style-dore') p.skin = 'gold';
if (e.kind === 'pet' && meta.char) pets.push({ char: meta.char, position: meta.position ?? 'left' });
if (e.kind === 'element-skin') p.elementSkin = true; if (e.kind === 'ip-colors') p.ipColors = true;
if (e.kind.startsWith('send-skin-')) {
let meta2: any = {};
try { meta2 = e.metaJson ? JSON.parse(e.metaJson) : {}; } catch {}
if (!p.sendSkins) p.sendSkins = [];
p.sendSkins.push({ id: e.kind, char: meta2.char ?? '?', label: meta2.label });
} if (e.kind === 'rich-htmlcss') p.richHtmlcss = true;
if (e.kind === 'rich-js') p.richJs = true;
if (e.kind === 'no-file-limit') p.noFileLimit = true;
if (e.kind === 'audio-alert') p.audioAlert = true;
}
if (pets.length) p.pets = pets;
myPerks.value = p;
const { myPerks: p } = (await res.json()) as { myPerks?: Perks };
myPerks.value = p ?? {};
const { ip } = useWallet();
if (ip.value) setPerks(ip.value, p);
if (ip.value) setPerks(ip.value, myPerks.value);
} catch {
/* ignore */
}
@@ -186,7 +168,7 @@ export function useMessages() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: content.trim() || ' ',
content: content.trim(),
parentId: extras.parentId,
richMode: extras.richMode,
richContent: extras.richContent,
@@ -214,6 +196,8 @@ export function useMessages() {
fetchMyPerks();
});
// Note: viewer-own perks live in the module-level `myPerks` singleton; read
// them via `useMyPerks()` rather than off this return (consistency rule).
return {
messages,
loading,
@@ -222,7 +206,6 @@ export function useMessages() {
stats,
connected,
sendTyping,
get myPerks() { return myPerks; },
myIp,
fetchMyPerks,
};

View File

@@ -0,0 +1,28 @@
/**
* Safe JSON parser for the `metaJson` strings carried by products and
* entitlements. Returns the fallback on any parse error instead of throwing,
* so callers can drop their repetitive try/catch + `any` casts.
*/
export function parseMeta<T = Record<string, unknown>>(
json: string | null | undefined,
fallback: T = {} as T,
): T {
if (!json) return fallback;
try {
return JSON.parse(json) as T;
} catch {
return fallback;
}
}
/** Shape of a product's metaJson (all fields optional — depends on kind). */
export interface ProductMeta {
designs?: { id: string; char: string }[];
positions?: string[];
plans?: { id: string; label: string; price: number }[];
durations?: { days: number; extra: number }[];
formats?: { id: string; label: string; extra: number }[];
char?: string;
label?: string;
includes?: string[];
}

View File

@@ -1,6 +1,7 @@
import { ref } from 'vue';
import { useWallet } from './useWallet';
import { refreshMyPerks } from './useMessages';
import { parseMeta, type ProductMeta } from './useMeta';
/** Marketplace client: catalogue, my entitlements, purchase flow. */
@@ -84,10 +85,7 @@ export function useShop() {
function ownedPetChars(): string[] {
return entitlements.value
.filter((e) => e.kind === 'pet' && e.active)
.map((e) => {
try { return (JSON.parse(e.metaJson ?? '{}') as any).char ?? ''; }
catch { return ''; }
})
.map((e) => parseMeta<ProductMeta>(e.metaJson).char ?? '')
.filter(Boolean);
}

View File

@@ -1,6 +1,9 @@
import { ref, provide, inject, type InjectionKey, type Ref } from 'vue';
export type Theme = 'default' | 'bubble' | 'compact';
export type Theme = 'default' | 'bubble' | 'compact' | 'whatsapp';
/** Which message layout a theme uses (drives the dynamic <component :is>). */
export type Layout = 'classic' | 'bubble' | 'compact';
export interface ThemeContext {
theme: Ref<Theme>;
@@ -10,9 +13,22 @@ export interface ThemeContext {
export const THEME_KEY: InjectionKey<ThemeContext> = Symbol('xip-theme');
const THEMES: Record<Theme, { label: string; emoji: string }> = {
default: { label: 'Classique', emoji: '📋' },
bubble: { label: 'Bulles', emoji: '💬' },
compact: { label: 'Compact', emoji: '📐' },
default: { label: 'Classique', emoji: '📋' },
bubble: { label: 'Bulles', emoji: '💬' },
compact: { label: 'Compact', emoji: '📐' },
whatsapp: { label: 'WhatsApp', emoji: '💚' },
};
/**
* A theme = a message layout (component) + a CSS-variable palette (applied via a
* `data-theme` attribute on the app root). WhatsApp reuses the bubble layout with
* a green palette — no dedicated message component needed.
*/
const THEME_LAYOUT: Record<Theme, Layout> = {
default: 'classic',
bubble: 'bubble',
compact: 'compact',
whatsapp: 'bubble',
};
export function provideTheme() {
@@ -36,4 +52,4 @@ export function useTheme(): ThemeContext {
});
}
export { THEMES };
export { THEMES, THEME_LAYOUT };

View File

@@ -37,11 +37,42 @@
padding: 0;
}
/* ── Thèmes : palette par variables CSS, basculée via [data-theme] sur la racine app ──
Le défaut = palette XIP sombre/néon. Chaque thème ne redéfinit que les surfaces
à fort impact (fond, header, bulles, bouton d'envoi). */
:root {
--xip-app-bg: #080808;
--xip-bg: #090910;
--xip-header-bg: #0e0e16;
--xip-header-border: #1a1a2a;
--xip-bubble-other: #141422;
--xip-bubble-other-border: #222236;
--xip-bubble-sent: #0e1f30;
--xip-bubble-sent-border: #1a3a55;
--xip-accent: #00ddff;
--xip-send-bg: #004488;
--xip-send-fg: #00ddff;
}
[data-theme="whatsapp"] {
--xip-app-bg: #0b141a;
--xip-bg: #0b141a;
--xip-header-bg: #202c33;
--xip-header-border: #2a3942;
--xip-bubble-other: #202c33;
--xip-bubble-other-border: #2a3942;
--xip-bubble-sent: #005c4b; /* vert sortant signature WhatsApp */
--xip-bubble-sent-border: #047857;
--xip-accent: #00a884;
--xip-send-bg: #00a884;
--xip-send-fg: #ffffff;
}
html,
body,
#app {
height: 100%;
overflow: hidden;
background: #080808;
background: var(--xip-app-bg);
font-family: 'Lato', sans-serif;
}

View File

@@ -1,5 +1,5 @@
<template>
<div class="xip-app">
<div class="xip-app" :data-theme="theme">
<!-- Bandeau de stats temps réel, toujours visible -->
<StatsTicker :stats="stats" :connected="connected" />
@@ -17,69 +17,8 @@
<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 v-show="richMode === 'none'" 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>
<!-- Composer (texte / riche / pièces jointes / envoi) -->
<ChatComposer :replying-to="replyingTo" @clear-reply="cancelReply" />
</div>
</div>
</div>
@@ -87,21 +26,18 @@
<script setup lang="ts">
import { ref, computed } from '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 ChatHeader from '@/components/ChatHeader.vue';
import MessageList from '@/components/MessageList.vue';
import ChatComposer from '@/components/ChatComposer.vue';
import StatsTicker from '@/components/StatsTicker.vue';
import { useMessages, useMyPerks } from '@/composables/useMessages';
import { provideTheme } from '@/composables/useTheme';
provideTheme();
import { useAttachments } from '@/composables/useAttachments';
import { useAlert } from '@/composables/useAlert';
import { useCustomStyles } from '@/composables/useCustomStyles';
const { messages, sending, postMessage, stats, connected, sendTyping, myPerks, myIp } = useMessages();
const { uploadFile, kb } = useAttachments();
const { fireAlert } = useAlert();
const { theme } = provideTheme();
const { messages, stats, connected, myIp } = useMessages();
const { myPerks } = useMyPerks();
const { prefs: stylePrefs } = useCustomStyles();
const chatBgStyle = computed(() => {
@@ -114,17 +50,7 @@ const chatBgStyle = computed(() => {
};
});
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 ──
// ── Réponse (la bannière vit ici ; le composer envoie avec parentId) ──
const replyingTo = ref<{ id: string; authorIp: string } | null>(null);
function startReply(payload: { id: string; authorIp: string }): void {
replyingTo.value = payload;
@@ -132,76 +58,6 @@ function startReply(payload: { id: string; authorIp: string }): void {
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>
@@ -210,7 +66,7 @@ async function submit(): Promise<void> {
flex-direction: column;
width: 100vw;
height: 100dvh;
background: #080808;
background: var(--xip-app-bg);
overflow: hidden;
}
@@ -226,7 +82,7 @@ async function submit(): Promise<void> {
min-width: 0;
display: flex;
flex-direction: column;
background: #090910;
background: var(--xip-bg);
overflow: hidden;
}
@@ -244,76 +100,4 @@ async function submit(): Promise<void> {
.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: #1e1218; }
.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>

View File

@@ -76,14 +76,16 @@
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useShop, type PurchaseOptions } from '@/composables/useShop';
import { useWallet } from '@/composables/useWallet';
import { parseMeta, type ProductMeta } from '@/composables/useMeta';
import ProductCard from '@/components/shop/ProductCard.vue';
import MesPersos from '@/components/shop/MesPersos.vue';
const { products, loading, buying, lastError, lastSuccess, fetchProducts, fetchMe, owns, petCount, ownedPetChars, purchase } = useShop();
const { products, buying, lastError, lastSuccess, fetchProducts, fetchMe, owns, petCount, ownedPetChars, purchase } = useShop();
const { ip, freeMode, displayBalance, fetchWallet, topUp: walletTopUp } = useWallet();
// Navigation forcée par catégorie : pas de « Tout voir », on entre directement
// dans une rubrique organisée.
const categories = [
{ id: 'all', label: 'Tout voir' },
{ id: 'publicite', label: 'Publicité' },
{ id: 'abonnements', label: 'Abonnements' },
{ id: 'cosmetiques', label: 'Cosmétiques' },
@@ -91,20 +93,17 @@ const categories = [
{ id: 'promotions', label: 'Promotions' },
{ id: 'perso', label: '✨ Mes Persos' },
];
const activeCat = ref('all');
const activeCat = ref('publicite');
const visibleProducts = computed(() => {
const chars = ownedPetChars();
const base = activeCat.value === 'all'
? products.value
: products.value.filter((p) => p.category === activeCat.value);
return base.filter((p) => {
if (p.kind !== 'pet') return true;
try {
const designs: any[] = JSON.parse((p as any).metaJson ?? '{}').designs ?? [];
return products.value
.filter((p) => p.category === activeCat.value)
.filter((p) => {
if (p.kind !== 'pet') return true;
const designs = parseMeta<ProductMeta>(p.metaJson).designs ?? [];
return designs.some((d) => !chars.includes(d.char));
} catch { return true; }
});
});
});
async function onBuy(productId: string, options: PurchaseOptions): Promise<void> {