feat: thème WhatsApp + fix envoi rich/compact + nav shop + refactor
All checks were successful
Deploy XIP / deploy (push) Successful in 43s
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:
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
28
frontend/src/composables/useMeta.ts
Normal file
28
frontend/src/composables/useMeta.ts
Normal 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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user