Compare commits

...

2 Commits

Author SHA1 Message Date
arussac
d50f06d65a Merge branch 'main' of https://git.kerboul.me/anto/XIP
All checks were successful
Deploy XIP / deploy (push) Successful in 51s
2026-05-31 15:36:49 +02:00
arussac
d88b71b2c6 push 2026-05-31 15:35:59 +02:00
11 changed files with 227 additions and 31 deletions

View File

@@ -145,6 +145,77 @@ const PRODUCTS = [
sortOrder: 90, sortOrder: 90,
metaJson: JSON.stringify({ cooldownMs: 60000, maxDurationMs: 5000 }), 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; ] as const;
// ── Ad inventory (the 4 hardcoded joke ads, now real data) ────────────────── // ── Ad inventory (the 4 hardcoded joke ads, now real data) ──────────────────

View File

@@ -163,7 +163,7 @@ export async function purchase(
if ((await countActiveEntitlements(ip, product.id)) >= 1) if ((await countActiveEntitlements(ip, product.id)) >= 1)
throw new PurchaseError("Déjà débloqué", 409); throw new PurchaseError("Déjà débloqué", 409);
grants.push({ kind: product.id }); grants.push({ kind: product.id });
if (product.id === "element-skin") visiblePerkChanged = false; // viewer-scoped if (product.id === "element-skin" || product.id === "ip-colors") visiblePerkChanged = false; // viewer-scoped
break; break;
} }
case "consumable": { case "consumable": {
@@ -176,6 +176,14 @@ export async function purchase(
} }
break; break;
} }
case "send-skin": {
if ((await countActiveEntitlements(ip, product.id)) >= 1)
throw new PurchaseError("Déjà débloqué", 409);
let skinMeta: any = {};
try { skinMeta = product.metaJson ? JSON.parse(product.metaJson) : {}; } catch {}
grants.push({ kind: product.id, meta: skinMeta });
break;
}
case "bundle": { case "bundle": {
// Cosmetic bundle: Style Doré + 1 pet. // Cosmetic bundle: Style Doré + 1 pet.
if ((await countActiveEntitlements(ip, "style-dore")) < 1) if ((await countActiveEntitlements(ip, "style-dore")) < 1)

View File

@@ -9,7 +9,8 @@
@click="$emit('send')" @click="$emit('send')"
@contextmenu.prevent="onRightClick" @contextmenu.prevent="onRightClick"
> >
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true"> <span v-if="activeSkinChar" class="skin-char">{{ activeSkinChar }}</span>
<svg v-else width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<polygon points="4,5 15,9 4,13 7,9" fill="currentColor" /> <polygon points="4,5 15,9 4,13 7,9" fill="currentColor" />
</svg> </svg>
</button> </button>
@@ -27,6 +28,12 @@ defineEmits<{ send: [] }>();
const { prefs } = useCustomStyles(); const { prefs } = useCustomStyles();
const { myPerks } = useMyPerks(); const { myPerks } = useMyPerks();
const activeSkinChar = computed(() => {
const skinId = prefs.sendSkin;
if (!skinId) return null;
return myPerks.value.sendSkins?.find((s) => s.id === skinId)?.char ?? null;
});
const btnStyle = computed(() => { const btnStyle = computed(() => {
const p = SEND_BUTTON_PRESETS[prefs.sendButton]; const p = SEND_BUTTON_PRESETS[prefs.sendButton];
return { background: p.bg, color: p.color, borderRadius: p.radius }; return { background: p.bg, color: p.color, borderRadius: p.radius };
@@ -64,4 +71,5 @@ function onRightClick(e: MouseEvent): void {
.send-btn:hover:not(:disabled) { filter: brightness(1.3); } .send-btn:hover:not(:disabled) { filter: brightness(1.3); }
.send-btn:active:not(:disabled) { filter: brightness(0.85); } .send-btn:active:not(:disabled) { filter: brightness(0.85); }
.send-btn:disabled { opacity: 0.35; cursor: not-allowed; } .send-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.skin-char { font-size: 18px; line-height: 1; }
</style> </style>

View File

@@ -42,11 +42,44 @@
</div> </div>
</section> </section>
<!-- Couleur de l'IP ────────────────────────────────────────── --> <!-- Skin du bouton d'envoi ───────────────────────────────────── -->
<section class="section" :class="{ locked: !myPerks.elementSkin }"> <section class="section" :class="{ locked: !hasSendSkins }">
<h2 class="section-title">
🖱️ Skin du bouton d'envoi
<span v-if="!hasSendSkins" class="lock-badge">Achetez un skin dans le shop</span>
</h2>
<template v-if="hasSendSkins">
<div class="style-grid">
<button
class="style-tile"
:class="{ 'style-tile--active': prefs.sendSkin === '' }"
@click="prefs.sendSkin = ''"
type="button"
>
<span class="style-swatch" style="font-size:14px"></span>
<span class="style-label">Défaut</span>
</button>
<button
v-for="s in myPerks.sendSkins"
:key="s.id"
class="style-tile"
:class="{ 'style-tile--active': prefs.sendSkin === s.id }"
@click="prefs.sendSkin = s.id"
type="button"
>
<span class="style-swatch" style="font-size:20px">{{ s.char }}</span>
<span class="style-label">{{ s.label ?? s.id.replace('send-skin-', '') }}</span>
</button>
</div>
</template>
<p v-else class="section-sub">Aucun skin possédé pour l'instant.</p>
</section>
<!-- ── Couleur de l'IP -->
<section class="section" :class="{ locked: !myPerks.ipColors }">
<h2 class="section-title"> <h2 class="section-title">
🎨 Couleur de mon IP 🎨 Couleur de mon IP
<span v-if="!myPerks.elementSkin" class="lock-badge">🔒 Skin d'éléments requis</span> <span v-if="!myPerks.ipColors" class="lock-badge">🔒 Palette IP requise</span>
</h2> </h2>
<p v-if="myIp" class="section-sub">IP&nbsp;: <code class="code-ip" :style="ipPreviewStyle">{{ myIp }}</code></p> <p v-if="myIp" class="section-sub">IP&nbsp;: <code class="code-ip" :style="ipPreviewStyle">{{ myIp }}</code></p>
<div class="style-grid"> <div class="style-grid">
@@ -55,7 +88,7 @@
:key="opt.value" :key="opt.value"
class="style-tile" class="style-tile"
:class="{ 'style-tile--active': currentIpColor === opt.value }" :class="{ 'style-tile--active': currentIpColor === opt.value }"
:disabled="!myPerks.elementSkin" :disabled="!myPerks.ipColors"
@click="setIpColor(opt.value)" @click="setIpColor(opt.value)"
type="button" type="button"
> >
@@ -144,6 +177,7 @@ const ownedPets = computed(() => {
}); });
}); });
const hasPets = computed(() => ownedPets.value.length > 0); const hasPets = computed(() => ownedPets.value.length > 0);
const hasSendSkins = computed(() => (myPerks.value.sendSkins?.length ?? 0) > 0);
const activePet = computed(() => const activePet = computed(() =>
myIp.value && myIp.value in prefs.ipPets ? prefs.ipPets[myIp.value] : (ownedPets.value[0]?.char ?? '') myIp.value && myIp.value in prefs.ipPets ? prefs.ipPets[myIp.value] : (ownedPets.value[0]?.char ?? '')
); );

View File

@@ -48,11 +48,11 @@
<input v-model="url" class="opt-input" type="text" placeholder="URL de destination (optionnel)" /> <input v-model="url" class="opt-input" type="text" placeholder="URL de destination (optionnel)" />
</div> </div>
<!-- Options : Pet (grille + position) --> <!-- Options : Pet (grille des designs non encore possédés) -->
<div v-if="product.kind === 'pet'" class="opts"> <div v-if="product.kind === 'pet'" class="opts">
<div class="pet-grid"> <div class="pet-grid">
<button <button
v-for="d in designs" v-for="d in availableDesigns"
:key="d.id" :key="d.id"
class="pet-cell" class="pet-cell"
:class="{ active: petDesign === d.id }" :class="{ active: petDesign === d.id }"
@@ -60,12 +60,11 @@
type="button" type="button"
>{{ d.char }}</button> >{{ d.char }}</button>
</div> </div>
<div class="pet-pos">
<label v-for="pos in positions" :key="pos" class="opt-radio opt-radio--sm" :class="{ active: petPosition === pos }">
<input type="radio" :value="pos" v-model="petPosition" />
<span>{{ posLabel(pos) }}</span>
</label>
</div> </div>
<!-- Preview : Skin de bouton -->
<div v-if="product.kind === 'send-skin'" class="send-skin-preview">
<div class="skin-btn-demo">{{ meta.char }}</div>
</div> </div>
<!-- Stock limité --> <!-- Stock limité -->
@@ -107,13 +106,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref, watch } from 'vue';
import type { Product, PurchaseOptions } from '@/composables/useShop'; import type { Product, PurchaseOptions } from '@/composables/useShop';
const props = defineProps<{ const props = defineProps<{
product: Product; product: Product;
buying: boolean; buying: boolean;
owns: (kind: string) => boolean; owns: (kind: string) => boolean;
ownedPetChars: string[];
petCount: number; petCount: number;
freeMode: boolean; freeMode: boolean;
}>(); }>();
@@ -141,11 +141,19 @@ const url = ref('');
// Pet // Pet
const designs = computed(() => meta.value.designs ?? []); const designs = computed(() => meta.value.designs ?? []);
const positions = computed<string[]>(() => meta.value.positions ?? ['left', 'right', 'both']);
const petDesign = ref<string>(''); const petDesign = ref<string>('');
const petPosition = ref<'left' | 'right' | 'both'>('left'); const availableDesigns = computed(() =>
designs.value.filter((d: any) => !props.ownedPetChars.includes(d.char))
);
watch(availableDesigns, (ds) => {
if (ds.length > 0 && !ds.find((d: any) => d.id === petDesign.value)) {
petDesign.value = (ds[0] as any).id;
}
}, { immediate: true });
const icon = computed(() => { const icon = computed(() => {
if (props.product.id === 'ip-colors') return '🎨';
if (props.product.kind === 'send-skin') return meta.value.char ?? '🖱️';
switch (props.product.kind) { switch (props.product.kind) {
case 'ad-frame': return '📣'; case 'ad-frame': return '📣';
case 'subscription': return '🚫'; case 'subscription': return '🚫';
@@ -180,6 +188,7 @@ const ownedAlready = computed(() => {
if (k === 'rich') return props.owns(props.product.id); if (k === 'rich') return props.owns(props.product.id);
if (k === 'unlock') return props.owns(props.product.id); if (k === 'unlock') return props.owns(props.product.id);
if (k === 'ad-frame') return props.owns('ad-frame'); if (k === 'ad-frame') return props.owns('ad-frame');
if (k === 'send-skin') return props.owns(props.product.id);
return false; return false;
}); });
@@ -201,9 +210,6 @@ const stockPct = computed(() =>
function fmt(centi: number): string { function fmt(centi: number): string {
return (centi / 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return (centi / 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
function posLabel(p: string): string {
return p === 'left' ? 'Gauche' : p === 'right' ? 'Droite' : 'Les deux';
}
function onBuy(): void { function onBuy(): void {
const options: PurchaseOptions = {}; const options: PurchaseOptions = {};
@@ -214,9 +220,8 @@ function onBuy(): void {
options.url = url.value || undefined; options.url = url.value || undefined;
} }
if (props.product.kind === 'pet' || props.product.id === 'bundle-cosmetic') { if (props.product.kind === 'pet' || props.product.id === 'bundle-cosmetic') {
const d = designs.value.find((x: any) => x.id === petDesign.value) ?? designs.value[0]; const d = availableDesigns.value.find((x: any) => x.id === petDesign.value) ?? availableDesigns.value[0];
if (d) { options.petDesign = d.id; options.petChar = d.char; } if (d) { options.petDesign = (d as any).id; options.petChar = (d as any).char; }
options.petPosition = petPosition.value;
} }
emit('buy', props.product.id, options); emit('buy', props.product.id, options);
} }
@@ -291,6 +296,14 @@ function onBuy(): void {
.pet-cell.active { border-color: #8844aa; } .pet-cell.active { border-color: #8844aa; }
.pet-pos { display: flex; gap: 6px; } .pet-pos { display: flex; gap: 6px; }
.send-skin-preview { display: flex; justify-content: center; padding: 8px 0; }
.skin-btn-demo {
width: 52px; height: 52px; border-radius: 50%;
background: #151525; border: 1px solid #30306a;
display: flex; align-items: center; justify-content: center;
font-size: 24px;
}
.stock { display: flex; flex-direction: column; gap: 4px; } .stock { display: flex; flex-direction: column; gap: 4px; }
.stock-bar { height: 6px; background: #1a1a2a; border-radius: 3px; overflow: hidden; } .stock-bar { height: 6px; background: #1a1a2a; border-radius: 3px; overflow: hidden; }
.stock-fill { height: 100%; background: linear-gradient(90deg, #ffaa00, #ff4444); } .stock-fill { height: 100%; background: linear-gradient(90deg, #ffaa00, #ff4444); }

View File

@@ -50,6 +50,7 @@ export const PET_OPTIONS: { value: string; label: string }[] = [
export interface CustomStylePrefs { export interface CustomStylePrefs {
sendButton: SendButtonKey; sendButton: SendButtonKey;
sendSkin: string; // send-skin product id, or '' for default arrow
adFrame: AdFrameKey; adFrame: AdFrameKey;
ipColors: Record<string, string>; // ip → hex or 'auto' ipColors: Record<string, string>; // ip → hex or 'auto'
ipPets: Record<string, string>; // ip → emoji or '' ipPets: Record<string, string>; // ip → emoji or ''
@@ -57,7 +58,7 @@ export interface CustomStylePrefs {
} }
function defaults(): CustomStylePrefs { function defaults(): CustomStylePrefs {
return { sendButton: 'default', adFrame: 'default', ipColors: {}, ipPets: {}, chatBgUrl: '' }; return { sendButton: 'default', sendSkin: '', adFrame: 'default', ipColors: {}, ipPets: {}, chatBgUrl: '' };
} }
function load(): CustomStylePrefs { function load(): CustomStylePrefs {

View File

@@ -55,8 +55,13 @@ export async function refreshMyPerks(): Promise<void> {
if (e.kind === 'noads') { p.noads = true; if (meta.plan === 'annual') p.badge = true; } 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 === 'style-dore') p.skin = 'gold';
if (e.kind === 'pet' && meta.char) pets.push({ char: meta.char, position: meta.position ?? 'left' }); 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 === 'element-skin') p.elementSkin = true; if (e.kind === 'ip-colors') p.ipColors = true;
if (e.kind === 'rich-htmlcss') p.richHtmlcss = 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 === 'rich-js') p.richJs = true;
if (e.kind === 'no-file-limit') p.noFileLimit = true; if (e.kind === 'no-file-limit') p.noFileLimit = true;
if (e.kind === 'audio-alert') p.audioAlert = true; if (e.kind === 'audio-alert') p.audioAlert = true;

View File

@@ -16,6 +16,8 @@ export interface Perks {
elementSkin?: boolean; elementSkin?: boolean;
richHtmlcss?: boolean; richHtmlcss?: boolean;
richJs?: boolean; richJs?: boolean;
ipColors?: boolean;
sendSkins?: { id: string; char: string; label?: string }[];
noFileLimit?: boolean; noFileLimit?: boolean;
audioAlert?: boolean; audioAlert?: boolean;
} }

View File

@@ -81,6 +81,16 @@ export function useShop() {
return entitlements.value.filter((e) => e.kind === 'pet' && e.active).length; return entitlements.value.filter((e) => e.kind === 'pet' && e.active).length;
} }
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 ''; }
})
.filter(Boolean);
}
async function purchase(productId: string, options: PurchaseOptions = {}): Promise<boolean> { async function purchase(productId: string, options: PurchaseOptions = {}): Promise<boolean> {
buying.value = productId; buying.value = productId;
lastError.value = null; lastError.value = null;
@@ -119,6 +129,7 @@ export function useShop() {
fetchMe, fetchMe,
owns, owns,
petCount, petCount,
ownedPetChars,
purchase, purchase,
}; };
} }

View File

@@ -1,3 +1,36 @@
/* latin-ext */
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/lato/v25/S6uyw4BMUTPHjxAwXjeu.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/lato/v25/S6uyw4BMUTPHjx4wXg.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/lato/v25/S6u9w4BMUTPHh6UVSwaPGR_p.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/s/lato/v25/S6u9w4BMUTPHh6UVSwiPGQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
*, *::before, *::after { *, *::before, *::after {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
@@ -10,4 +43,5 @@ body,
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
background: #080808; background: #080808;
font-family: 'Lato', sans-serif;
} }

View File

@@ -59,6 +59,7 @@
:buying="buying === p.id" :buying="buying === p.id"
:owns="owns" :owns="owns"
:pet-count="petCount()" :pet-count="petCount()"
:owned-pet-chars="ownedPetChars()"
:free-mode="freeMode" :free-mode="freeMode"
@buy="onBuy" @buy="onBuy"
@go-perso="activeCat = 'perso'" @go-perso="activeCat = 'perso'"
@@ -78,7 +79,7 @@ import { useWallet } from '@/composables/useWallet';
import ProductCard from '@/components/shop/ProductCard.vue'; import ProductCard from '@/components/shop/ProductCard.vue';
import MesPersos from '@/components/shop/MesPersos.vue'; import MesPersos from '@/components/shop/MesPersos.vue';
const { products, loading, buying, lastError, lastSuccess, fetchProducts, fetchMe, owns, petCount, purchase } = useShop(); const { products, loading, buying, lastError, lastSuccess, fetchProducts, fetchMe, owns, petCount, ownedPetChars, purchase } = useShop();
const { ip, freeMode, displayBalance, fetchWallet, topUp: walletTopUp } = useWallet(); const { ip, freeMode, displayBalance, fetchWallet, topUp: walletTopUp } = useWallet();
const categories = [ const categories = [
@@ -92,11 +93,19 @@ const categories = [
]; ];
const activeCat = ref('all'); const activeCat = ref('all');
const visibleProducts = computed(() => const visibleProducts = computed(() => {
activeCat.value === 'all' const chars = ownedPetChars();
const base = activeCat.value === 'all'
? products.value ? products.value
: products.value.filter((p) => p.category === activeCat.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 designs.some((d) => !chars.includes(d.char));
} catch { return true; }
});
});
async function onBuy(productId: string, options: PurchaseOptions): Promise<void> { async function onBuy(productId: string, options: PurchaseOptions): Promise<void> {
await purchase(productId, options); await purchase(productId, options);