Compare commits

..

2 Commits

Author SHA1 Message Date
arussac
48a99514b2 Merge branch 'main' of https://git.kerboul.me/anto/XIP
Some checks failed
Deploy XIP / deploy (push) Failing after 1s
2026-05-31 15:16:10 +02:00
arussac
21e35107c7 feat: update styles and enhance pet purchase flow in marketplace components 2026-05-31 15:15:48 +02:00
11 changed files with 99 additions and 99 deletions

View File

@@ -132,8 +132,6 @@ export async function purchase(
break; break;
} }
case "pet": { case "pet": {
if ((await countActiveEntitlements(ip, "pet")) >= 3)
throw new PurchaseError("Maximum 3 pets actifs", 409);
const char = options.petChar ?? "♥"; const char = options.petChar ?? "♥";
grants.push({ grants.push({
kind: "pet", kind: "pet",

View File

@@ -115,11 +115,11 @@ onMounted(fetchAds);
font-weight: bold; font-weight: bold;
margin: 0; margin: 0;
} }
.ad-brand--blue { color: #5555cc; text-shadow: 0 0 8px #4444aa; } .ad-brand--blue { color: #4455aa; }
.ad-brand--green { color: #33aa55; text-shadow: 0 0 8px #225533; } .ad-brand--green { color: #336644; }
.ad-brand--purple { color: #9944dd; text-shadow: 0 0 8px #6622aa; } .ad-brand--purple { color: #6633aa; }
.ad-brand--user { color: #ffcc44; text-shadow: 0 0 8px #aa8822; } .ad-brand--user { color: #998833; }
.ad-brand--casino { color: #ff5533; text-shadow: 0 0 8px #aa2200; } .ad-brand--casino { color: #884433; }
.ad-sub { .ad-sub {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;

View File

@@ -57,8 +57,7 @@ const { ip, freeMode, displayBalance } = useWallet();
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 18px; font-size: 18px;
font-weight: bold; font-weight: bold;
color: #00eeff; color: #7ab8cc;
text-shadow: 0 0 10px #00ccff99;
} }
.chat-label { .chat-label {
@@ -72,14 +71,13 @@ const { ip, freeMode, displayBalance } = useWallet();
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: #00ff88; background: #44aa66;
box-shadow: 0 0 6px #00ff44;
} }
.online-count { .online-count {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 11px; font-size: 11px;
color: #33ff66; color: #557766;
} }
.me-ip { .me-ip {
@@ -98,26 +96,25 @@ const { ip, freeMode, displayBalance } = useWallet();
padding: 3px 10px; padding: 3px 10px;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
} }
.balance-coin { color: #ffcc44; font-size: 11px; } .balance-coin { color: #aa8833; font-size: 11px; }
.balance-val { color: #ffdd66; font-size: 13px; font-weight: bold; text-shadow: 0 0 8px #ffaa0055; } .balance-val { color: #ccaa44; font-size: 13px; font-weight: bold; }
.balance-unit { color: #886633; font-size: 9px; } .balance-unit { color: #886633; font-size: 9px; }
.balance--free .balance-val { color: #33ff99; text-shadow: 0 0 8px #00ff6655; } .balance--free .balance-val { color: #44aa77; }
.balance--free .balance-coin { color: #33ff99; } .balance--free .balance-coin { color: #44aa77; }
.shop-link { .shop-link {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 12px; font-size: 12px;
font-weight: bold; font-weight: bold;
color: #00eeff; color: #6699aa;
text-decoration: none; text-decoration: none;
border: 1px solid #00eeff55; border: 1px solid #33445566;
border-radius: 12px; border-radius: 12px;
padding: 4px 12px; padding: 4px 12px;
transition: background 0.15s, box-shadow 0.15s; transition: background 0.15s;
} }
.shop-link:hover { .shop-link:hover {
background: #00eeff14; background: #1a2530;
box-shadow: 0 0 10px #00ccff44;
} }
.channel-badge { .channel-badge {

View File

@@ -47,7 +47,7 @@ onMounted(fetchAds);
background: #100400; background: #100400;
border: 2px solid #ff2200; border: 2px solid #ff2200;
border-radius: 6px; border-radius: 6px;
box-shadow: 0 0 18px #ff220055; box-shadow: none;
} }
/* ── En-tête rouge ── */ /* ── En-tête rouge ── */
@@ -64,7 +64,7 @@ onMounted(fetchAds);
font-size: 15px; font-size: 15px;
font-weight: bold; font-weight: bold;
color: #ff5533; color: #ff5533;
text-shadow: 0 0 8px #ff2200;
margin: 0; margin: 0;
} }
@@ -87,7 +87,7 @@ onMounted(fetchAds);
font-size: 32px; font-size: 32px;
font-weight: bold; font-weight: bold;
color: #ffdd00; color: #ffdd00;
text-shadow: 0 0 14px #99660099;
margin: 0; margin: 0;
} }
@@ -117,7 +117,7 @@ onMounted(fetchAds);
font-size: 30px; font-size: 30px;
font-weight: bold; font-weight: bold;
color: #ffffff; color: #ffffff;
text-shadow: 0 0 10px #ffdd00;
} }
/* ── CTA ── */ /* ── CTA ── */
@@ -136,13 +136,11 @@ onMounted(fetchAds);
cursor: pointer; cursor: pointer;
text-align: center; text-align: center;
text-decoration: none; text-decoration: none;
text-shadow: 0 0 6px #ff2200; transition: background 0.15s;
box-shadow: 0 0 8px #ff220044;
transition: box-shadow 0.15s;
} }
.casino-cta:hover { .casino-cta:hover {
box-shadow: 0 0 16px #ff220088;
} }
.disclaimer { .disclaimer {

View File

@@ -95,8 +95,8 @@ const items = computed<Chip[]>(() => {
display: flex; display: flex;
align-items: stretch; align-items: stretch;
background: #0a0a12; background: #0a0a12;
border-bottom: 1px solid #00eeff33; border-bottom: 1px solid #1a1a2a;
box-shadow: inset 0 -1px 0 #00eeff14, 0 2px 14px #00000066; box-shadow: 0 2px 8px #00000066;
overflow: hidden; overflow: hidden;
} }
@@ -110,15 +110,14 @@ const items = computed<Chip[]>(() => {
gap: 7px; gap: 7px;
padding: 0 14px; padding: 0 14px;
background: #0e0e18; background: #0e0e18;
border-right: 1px solid #00eeff33; border-right: 1px solid #1a1a2a;
box-shadow: 6px 0 12px #0a0a12; box-shadow: 4px 0 8px #0a0a12;
} }
.ticker-dot { .ticker-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: #00ff88; background: #44996655;
box-shadow: 0 0 8px #00ff66;
animation: blink 1.5s ease-in-out infinite; animation: blink 1.5s ease-in-out infinite;
} }
.ticker-badge-txt { .ticker-badge-txt {
@@ -126,17 +125,14 @@ const items = computed<Chip[]>(() => {
font-size: 11px; font-size: 11px;
font-weight: bold; font-weight: bold;
letter-spacing: 2px; letter-spacing: 2px;
color: #00ff88; color: #448866;
text-shadow: 0 0 8px #00ff4466;
} }
.ticker.is-off .ticker-dot { .ticker.is-off .ticker-dot {
background: #ff3344; background: #884444;
box-shadow: 0 0 8px #ff2233;
animation: none; animation: none;
} }
.ticker.is-off .ticker-badge-txt { .ticker.is-off .ticker-badge-txt {
color: #ff5566; color: #885555;
text-shadow: none;
} }
@keyframes blink { @keyframes blink {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
@@ -206,10 +202,10 @@ const items = computed<Chip[]>(() => {
color: #50506e; color: #50506e;
} }
.chip--cyan .chip-val { color: #00eeff; text-shadow: 0 0 9px #00ccff55; } .chip--cyan .chip-val { color: #5599aa; }
.chip--green .chip-val { color: #33ff77; text-shadow: 0 0 9px #00ff4455; } .chip--green .chip-val { color: #447755; }
.chip--magenta .chip-val { color: #ff44cc; text-shadow: 0 0 9px #ff22aa55; } .chip--magenta .chip-val { color: #885588; }
.chip--orange .chip-val { color: #ffaa44; text-shadow: 0 0 9px #ff880055; } .chip--orange .chip-val { color: #997744; }
/* Accessibilité : pas de défilement si l'utilisateur le refuse */ /* Accessibilité : pas de défilement si l'utilisateur le refuse */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {

View File

@@ -81,13 +81,20 @@
<span class="price-now">{{ fmt(effectivePrice) }}</span> <span class="price-now">{{ fmt(effectivePrice) }}</span>
<span class="price-unit">cr</span> <span class="price-unit">cr</span>
</div> </div>
<!-- Pets: redirige vers Mes Persos au lieu d'acheter --> <!-- Pets: bouton acheter + lien Mes Persos -->
<button <template v-if="product.kind === 'pet'">
v-if="product.kind === 'pet'" <button
class="buy buy--perso" class="buy"
@click="$emit('goPerso')" :disabled="disabled"
type="button" @click="onBuy"
>✨ Mes Persos</button> type="button"
>{{ buyLabel }}</button>
<button
class="buy buy--perso"
@click="$emit('goPerso')"
type="button"
> Mes Persos</button>
</template>
<button <button
v-else v-else
class="buy" class="buy"
@@ -176,16 +183,14 @@ const ownedAlready = computed(() => {
return false; return false;
}); });
const petFull = computed(() => props.product.kind === 'pet' && props.petCount >= 3);
const soldOut = computed(() => props.product.stockLimit != null && props.product.stockSold >= props.product.stockLimit); const soldOut = computed(() => props.product.stockLimit != null && props.product.stockSold >= props.product.stockLimit);
const disabled = computed(() => props.buying || ownedAlready.value || petFull.value || soldOut.value); const disabled = computed(() => props.buying || ownedAlready.value || soldOut.value);
const buyLabel = computed(() => { const buyLabel = computed(() => {
if (props.buying) return '...'; if (props.buying) return '...';
if (soldOut.value) return 'Épuisé'; if (soldOut.value) return 'Épuisé';
if (ownedAlready.value) return 'Possédé ✓'; if (ownedAlready.value) return 'Possédé ✓';
if (petFull.value) return 'Max 3 pets';
return props.freeMode ? 'Obtenir (gratuit)' : 'Acheter'; return props.freeMode ? 'Obtenir (gratuit)' : 'Acheter';
}); });
@@ -242,7 +247,7 @@ function onBuy(): void {
letter-spacing: 0.5px; letter-spacing: 0.5px;
padding: 3px 9px; padding: 3px 9px;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 0 10px #ff226688; box-shadow: none;
} }
.card-head { display: flex; gap: 12px; align-items: flex-start; } .card-head { display: flex; gap: 12px; align-items: flex-start; }
@@ -256,7 +261,7 @@ function onBuy(): void {
} }
.prev-ip { font-family: 'Courier New', monospace; font-size: 12px; font-weight: bold; } .prev-ip { font-family: 'Courier New', monospace; font-size: 12px; font-weight: bold; }
.prev-plain { color: #666688; } .prev-plain { color: #666688; }
.prev-gold { color: #ffdd44; text-shadow: 0 0 8px #ffaa00cc; } .prev-gold { color: #aa8833; }
.prev-arrow { color: #444466; } .prev-arrow { color: #444466; }
.opts { display: flex; flex-direction: column; gap: 8px; } .opts { display: flex; flex-direction: column; gap: 8px; }
@@ -283,7 +288,7 @@ function onBuy(): void {
aspect-ratio: 1; background: #0c0c16; border: 1px solid #20203a; border-radius: 6px; aspect-ratio: 1; background: #0c0c16; border: 1px solid #20203a; border-radius: 6px;
font-size: 18px; color: #ccccee; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 18px; color: #ccccee; cursor: pointer; display: flex; align-items: center; justify-content: center;
} }
.pet-cell.active { border-color: #ff44cc; box-shadow: 0 0 8px #ff44cc55; } .pet-cell.active { border-color: #8844aa; }
.pet-pos { display: flex; gap: 6px; } .pet-pos { display: flex; gap: 6px; }
.stock { display: flex; flex-direction: column; gap: 4px; } .stock { display: flex; flex-direction: column; gap: 4px; }
@@ -291,18 +296,18 @@ function onBuy(): void {
.stock-fill { height: 100%; background: linear-gradient(90deg, #ffaa00, #ff4444); } .stock-fill { height: 100%; background: linear-gradient(90deg, #ffaa00, #ff4444); }
.stock-txt { font-size: 10px; color: #886644; } .stock-txt { font-size: 10px; color: #886644; }
.card-foot { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 6px; } .card-foot { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 6px; margin-top: auto; padding-top: 6px; }
.price { display: flex; align-items: baseline; gap: 6px; } .price { display: flex; align-items: baseline; gap: 6px; }
.price-old { font-size: 12px; color: #555; text-decoration: line-through; } .price-old { font-size: 12px; color: #555; text-decoration: line-through; }
.price-now { font-size: 20px; font-weight: bold; color: #ffdd66; font-family: 'Courier New', monospace; text-shadow: 0 0 10px #ffaa0044; } .price-now { font-size: 20px; font-weight: bold; color: #ccaa44; font-family: 'Courier New', monospace; }
.price-unit { font-size: 11px; color: #886633; } .price-unit { font-size: 11px; color: #886633; }
.buy { .buy {
background: #004488; border: 1px solid #0066aa; color: #00ddff; background: #004488; border: 1px solid #0066aa; color: #00ddff;
font-size: 13px; font-weight: bold; padding: 9px 18px; border-radius: 20px; cursor: pointer; font-size: 13px; font-weight: bold; padding: 9px 18px; border-radius: 20px; cursor: pointer;
box-shadow: 0 0 12px #00448855; transition: background 0.15s, box-shadow 0.15s; transition: background 0.15s;
} }
.buy:hover:not(:disabled) { background: #005599; box-shadow: 0 0 18px #00ddff55; } .buy:hover:not(:disabled) { background: #1a4466; }
.buy:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; } .buy:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; }
.buy--perso { .buy--perso {

View File

@@ -1,5 +1,5 @@
/** Couleurs assignées de façon déterministe à chaque adresse IP */ /** Couleurs assignées de façon déterministe à chaque adresse IP */
const PALETTE = ['#666688', '#00ddff', '#ff00cc', '#00ee77', '#ff8844'] as const; const PALETTE = ['#7777aa', '#4499bb', '#aa4499', '#338866', '#aa6633'] as const;
export function getIpColor(ip: string): string { export function getIpColor(ip: string): string {
// djb2 hash // djb2 hash
@@ -11,7 +11,7 @@ export function getIpColor(ip: string): string {
} }
export function getIpGlow(color: string): string { export function getIpGlow(color: string): string {
return color === '#666688' ? 'none' : `0 0 8px ${color}80`; return 'none';
} }
/** Gold skin (Style Doré) — overrides the djb2 palette for owners. */ /** Gold skin (Style Doré) — overrides the djb2 palette for owners. */
@@ -28,6 +28,5 @@ export function getIpColorWithPerks(ip: string, perks?: PerkLike | null): string
} }
export function getIpGlowWithPerks(ip: string, perks?: PerkLike | null): string { export function getIpGlowWithPerks(ip: string, perks?: PerkLike | null): string {
if (perks?.skin === 'gold') return `0 0 10px ${GOLD}cc`; return 'none';
return getIpGlow(getIpColor(ip));
} }

View File

@@ -39,6 +39,37 @@ export interface Message extends Reply {
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000'; const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
/** Refresh the viewer's own perks from the server (callable from anywhere). */
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 === '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 { ip } = useWallet();
if (ip.value) setPerks(ip.value, p);
} catch {
/* ignore */
}
}
export function useMessages() { export function useMessages() {
const messages = ref<Message[]>([]); const messages = ref<Message[]>([]);
const loading = ref(false); const loading = ref(false);
@@ -102,32 +133,7 @@ export function useMessages() {
// myPerks is module-level; this ref is the same reference. // myPerks is module-level; this ref is the same reference.
async function fetchMyPerks(): Promise<void> { async function fetchMyPerks(): Promise<void> {
try { return refreshMyPerks();
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 === '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.slice(0, 3);
myPerks.value = p;
if (myIp.value) setPerks(myIp.value, p);
} catch {
/* ignore */
}
} }
const { stats, connected, sendTyping } = useRealtime({ const { stats, connected, sendTyping } = useRealtime({

View File

@@ -1,5 +1,6 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { useWallet } from './useWallet'; import { useWallet } from './useWallet';
import { refreshMyPerks } from './useMessages';
/** Marketplace client: catalogue, my entitlements, purchase flow. */ /** Marketplace client: catalogue, my entitlements, purchase flow. */
@@ -96,8 +97,8 @@ export function useShop() {
return false; return false;
} }
lastSuccess.value = `Acheté : ${productId}`; lastSuccess.value = `Acheté : ${productId}`;
// Refresh wallet + my entitlements (WS also pushes wallet, this is belt-and-braces). // Refresh wallet + my entitlements + myPerks (WS also pushes wallet, this is belt-and-braces).
await Promise.all([fetchWallet(), fetchMe(), fetchProducts()]); await Promise.all([fetchWallet(), fetchMe(), fetchProducts(), refreshMyPerks()]);
return true; return true;
} catch { } catch {
lastError.value = 'Réseau indisponible'; lastError.value = 'Réseau indisponible';

View File

@@ -281,7 +281,7 @@ async function submit(): Promise<void> {
} }
.icon-btn:hover { background: #1c1c2e; } .icon-btn:hover { background: #1c1c2e; }
.icon-btn--alert { border-color: #aa3344; } .icon-btn--alert { border-color: #aa3344; }
.icon-btn--alert:hover { background: #2a1418; box-shadow: 0 0 10px #ff224455; } .icon-btn--alert:hover { background: #1e1218; }
.field-wrap { flex: 1; position: relative; display: flex; align-items: center; } .field-wrap { flex: 1; position: relative; display: flex; align-items: center; }
.input-field { .input-field {

View File

@@ -156,17 +156,17 @@ onUnmounted(() => { if (timer) clearInterval(timer); });
border: 1px solid #00aaff44; border-radius: 10px; padding: 4px 10px; border: 1px solid #00aaff44; border-radius: 10px; padding: 4px 10px;
} }
.sh-back:hover { background: #00aaff14; } .sh-back:hover { background: #00aaff14; }
.sh-title { font-size: 18px; font-weight: bold; color: #00eeff; text-shadow: 0 0 10px #00ccff99; } .sh-title { font-size: 18px; font-weight: bold; color: #6699aa; }
.sh-sub { font-size: 13px; color: #8888aa; } .sh-sub { font-size: 13px; color: #8888aa; }
.sh-right { display: flex; align-items: center; gap: 12px; } .sh-right { display: flex; align-items: center; gap: 12px; }
.sh-ip { font-family: 'Courier New', monospace; font-size: 11px; color: #5566aa; } .sh-ip { font-family: 'Courier New', monospace; font-size: 11px; color: #5566aa; }
.sh-balance { font-family: 'Courier New', monospace; font-size: 15px; font-weight: bold; color: #ffdd66; text-shadow: 0 0 10px #ffaa0044; } .sh-balance { font-family: 'Courier New', monospace; font-size: 15px; font-weight: bold; color: #ccaa44; }
.sh-balance.free { color: #33ff99; text-shadow: 0 0 10px #00ff6644; } .sh-balance.free { color: #44aa77; }
.sh-cr { font-size: 10px; color: #886633; } .sh-cr { font-size: 10px; color: #886633; }
.sh-topup { .sh-topup {
background: #1a2a1a; border: 1px solid #33aa55; color: #44dd77; background: #1a2a1a; border: 1px solid #33aa55; color: #44dd77;
font-size: 12px; font-weight: bold; padding: 6px 14px; border-radius: 16px; cursor: pointer; font-size: 12px; font-weight: bold; padding: 6px 14px; border-radius: 16px; cursor: pointer;
box-shadow: 0 0 10px #33aa5533; box-shadow: none;
} }
.sh-topup:hover { background: #234a23; } .sh-topup:hover { background: #234a23; }