feat: update styles and enhance pet purchase flow in marketplace components
This commit is contained in:
@@ -132,8 +132,6 @@ export async function purchase(
|
||||
break;
|
||||
}
|
||||
case "pet": {
|
||||
if ((await countActiveEntitlements(ip, "pet")) >= 3)
|
||||
throw new PurchaseError("Maximum 3 pets actifs", 409);
|
||||
const char = options.petChar ?? "♥";
|
||||
grants.push({
|
||||
kind: "pet",
|
||||
|
||||
@@ -115,11 +115,11 @@ onMounted(fetchAds);
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
}
|
||||
.ad-brand--blue { color: #5555cc; text-shadow: 0 0 8px #4444aa; }
|
||||
.ad-brand--green { color: #33aa55; text-shadow: 0 0 8px #225533; }
|
||||
.ad-brand--purple { color: #9944dd; text-shadow: 0 0 8px #6622aa; }
|
||||
.ad-brand--user { color: #ffcc44; text-shadow: 0 0 8px #aa8822; }
|
||||
.ad-brand--casino { color: #ff5533; text-shadow: 0 0 8px #aa2200; }
|
||||
.ad-brand--blue { color: #4455aa; }
|
||||
.ad-brand--green { color: #336644; }
|
||||
.ad-brand--purple { color: #6633aa; }
|
||||
.ad-brand--user { color: #998833; }
|
||||
.ad-brand--casino { color: #884433; }
|
||||
|
||||
.ad-sub {
|
||||
font-family: Arial, sans-serif;
|
||||
|
||||
@@ -57,8 +57,7 @@ const { ip, freeMode, displayBalance } = useWallet();
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #00eeff;
|
||||
text-shadow: 0 0 10px #00ccff99;
|
||||
color: #7ab8cc;
|
||||
}
|
||||
|
||||
.chat-label {
|
||||
@@ -72,14 +71,13 @@ const { ip, freeMode, displayBalance } = useWallet();
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #00ff88;
|
||||
box-shadow: 0 0 6px #00ff44;
|
||||
background: #44aa66;
|
||||
}
|
||||
|
||||
.online-count {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 11px;
|
||||
color: #33ff66;
|
||||
color: #557766;
|
||||
}
|
||||
|
||||
.me-ip {
|
||||
@@ -98,26 +96,25 @@ const { ip, freeMode, displayBalance } = useWallet();
|
||||
padding: 3px 10px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
.balance-coin { color: #ffcc44; font-size: 11px; }
|
||||
.balance-val { color: #ffdd66; font-size: 13px; font-weight: bold; text-shadow: 0 0 8px #ffaa0055; }
|
||||
.balance-coin { color: #aa8833; font-size: 11px; }
|
||||
.balance-val { color: #ccaa44; font-size: 13px; font-weight: bold; }
|
||||
.balance-unit { color: #886633; font-size: 9px; }
|
||||
.balance--free .balance-val { color: #33ff99; text-shadow: 0 0 8px #00ff6655; }
|
||||
.balance--free .balance-coin { color: #33ff99; }
|
||||
.balance--free .balance-val { color: #44aa77; }
|
||||
.balance--free .balance-coin { color: #44aa77; }
|
||||
|
||||
.shop-link {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #00eeff;
|
||||
color: #6699aa;
|
||||
text-decoration: none;
|
||||
border: 1px solid #00eeff55;
|
||||
border: 1px solid #33445566;
|
||||
border-radius: 12px;
|
||||
padding: 4px 12px;
|
||||
transition: background 0.15s, box-shadow 0.15s;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.shop-link:hover {
|
||||
background: #00eeff14;
|
||||
box-shadow: 0 0 10px #00ccff44;
|
||||
background: #1a2530;
|
||||
}
|
||||
|
||||
.channel-badge {
|
||||
|
||||
@@ -47,7 +47,7 @@ onMounted(fetchAds);
|
||||
background: #100400;
|
||||
border: 2px solid #ff2200;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 18px #ff220055;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ── En-tête rouge ── */
|
||||
@@ -64,7 +64,7 @@ onMounted(fetchAds);
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
color: #ff5533;
|
||||
text-shadow: 0 0 8px #ff2200;
|
||||
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ onMounted(fetchAds);
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #ffdd00;
|
||||
text-shadow: 0 0 14px #99660099;
|
||||
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ onMounted(fetchAds);
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
text-shadow: 0 0 10px #ffdd00;
|
||||
|
||||
}
|
||||
|
||||
/* ── CTA ── */
|
||||
@@ -136,13 +136,11 @@ onMounted(fetchAds);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 0 6px #ff2200;
|
||||
box-shadow: 0 0 8px #ff220044;
|
||||
transition: box-shadow 0.15s;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.casino-cta:hover {
|
||||
box-shadow: 0 0 16px #ff220088;
|
||||
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
|
||||
@@ -95,8 +95,8 @@ const items = computed<Chip[]>(() => {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: #0a0a12;
|
||||
border-bottom: 1px solid #00eeff33;
|
||||
box-shadow: inset 0 -1px 0 #00eeff14, 0 2px 14px #00000066;
|
||||
border-bottom: 1px solid #1a1a2a;
|
||||
box-shadow: 0 2px 8px #00000066;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -110,15 +110,14 @@ const items = computed<Chip[]>(() => {
|
||||
gap: 7px;
|
||||
padding: 0 14px;
|
||||
background: #0e0e18;
|
||||
border-right: 1px solid #00eeff33;
|
||||
box-shadow: 6px 0 12px #0a0a12;
|
||||
border-right: 1px solid #1a1a2a;
|
||||
box-shadow: 4px 0 8px #0a0a12;
|
||||
}
|
||||
.ticker-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #00ff88;
|
||||
box-shadow: 0 0 8px #00ff66;
|
||||
background: #44996655;
|
||||
animation: blink 1.5s ease-in-out infinite;
|
||||
}
|
||||
.ticker-badge-txt {
|
||||
@@ -126,17 +125,14 @@ const items = computed<Chip[]>(() => {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
color: #00ff88;
|
||||
text-shadow: 0 0 8px #00ff4466;
|
||||
color: #448866;
|
||||
}
|
||||
.ticker.is-off .ticker-dot {
|
||||
background: #ff3344;
|
||||
box-shadow: 0 0 8px #ff2233;
|
||||
background: #884444;
|
||||
animation: none;
|
||||
}
|
||||
.ticker.is-off .ticker-badge-txt {
|
||||
color: #ff5566;
|
||||
text-shadow: none;
|
||||
color: #885555;
|
||||
}
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
@@ -206,10 +202,10 @@ const items = computed<Chip[]>(() => {
|
||||
color: #50506e;
|
||||
}
|
||||
|
||||
.chip--cyan .chip-val { color: #00eeff; text-shadow: 0 0 9px #00ccff55; }
|
||||
.chip--green .chip-val { color: #33ff77; text-shadow: 0 0 9px #00ff4455; }
|
||||
.chip--magenta .chip-val { color: #ff44cc; text-shadow: 0 0 9px #ff22aa55; }
|
||||
.chip--orange .chip-val { color: #ffaa44; text-shadow: 0 0 9px #ff880055; }
|
||||
.chip--cyan .chip-val { color: #5599aa; }
|
||||
.chip--green .chip-val { color: #447755; }
|
||||
.chip--magenta .chip-val { color: #885588; }
|
||||
.chip--orange .chip-val { color: #997744; }
|
||||
|
||||
/* Accessibilité : pas de défilement si l'utilisateur le refuse */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -81,13 +81,20 @@
|
||||
<span class="price-now">{{ fmt(effectivePrice) }}</span>
|
||||
<span class="price-unit">cr</span>
|
||||
</div>
|
||||
<!-- Pets: redirige vers Mes Persos au lieu d'acheter -->
|
||||
<!-- Pets: bouton acheter + lien Mes Persos -->
|
||||
<template v-if="product.kind === 'pet'">
|
||||
<button
|
||||
class="buy"
|
||||
:disabled="disabled"
|
||||
@click="onBuy"
|
||||
type="button"
|
||||
>{{ buyLabel }}</button>
|
||||
<button
|
||||
v-if="product.kind === 'pet'"
|
||||
class="buy buy--perso"
|
||||
@click="$emit('goPerso')"
|
||||
type="button"
|
||||
>✨ Mes Persos</button>
|
||||
</template>
|
||||
<button
|
||||
v-else
|
||||
class="buy"
|
||||
@@ -176,16 +183,14 @@ const ownedAlready = computed(() => {
|
||||
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 disabled = computed(() => props.buying || ownedAlready.value || petFull.value || soldOut.value);
|
||||
const disabled = computed(() => props.buying || ownedAlready.value || soldOut.value);
|
||||
|
||||
const buyLabel = computed(() => {
|
||||
if (props.buying) return '...';
|
||||
if (soldOut.value) return 'Épuisé';
|
||||
if (ownedAlready.value) return 'Possédé ✓';
|
||||
if (petFull.value) return 'Max 3 pets';
|
||||
return props.freeMode ? 'Obtenir (gratuit)' : 'Acheter';
|
||||
});
|
||||
|
||||
@@ -242,7 +247,7 @@ function onBuy(): void {
|
||||
letter-spacing: 0.5px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 10px #ff226688;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.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-plain { color: #666688; }
|
||||
.prev-gold { color: #ffdd44; text-shadow: 0 0 8px #ffaa00cc; }
|
||||
.prev-gold { color: #aa8833; }
|
||||
.prev-arrow { color: #444466; }
|
||||
|
||||
.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;
|
||||
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; }
|
||||
|
||||
.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-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-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; }
|
||||
|
||||
.buy {
|
||||
background: #004488; border: 1px solid #0066aa; color: #00ddff;
|
||||
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--perso {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** 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 {
|
||||
// djb2 hash
|
||||
@@ -11,7 +11,7 @@ export function getIpColor(ip: 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. */
|
||||
@@ -28,6 +28,5 @@ export function getIpColorWithPerks(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 getIpGlow(getIpColor(ip));
|
||||
return 'none';
|
||||
}
|
||||
|
||||
@@ -39,6 +39,37 @@ 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). */
|
||||
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() {
|
||||
const messages = ref<Message[]>([]);
|
||||
const loading = ref(false);
|
||||
@@ -102,32 +133,7 @@ export function useMessages() {
|
||||
// myPerks is module-level; this ref is the same reference.
|
||||
|
||||
async function fetchMyPerks(): 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.slice(0, 3);
|
||||
myPerks.value = p;
|
||||
if (myIp.value) setPerks(myIp.value, p);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return refreshMyPerks();
|
||||
}
|
||||
|
||||
const { stats, connected, sendTyping } = useRealtime({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ref } from 'vue';
|
||||
import { useWallet } from './useWallet';
|
||||
import { refreshMyPerks } from './useMessages';
|
||||
|
||||
/** Marketplace client: catalogue, my entitlements, purchase flow. */
|
||||
|
||||
@@ -96,8 +97,8 @@ export function useShop() {
|
||||
return false;
|
||||
}
|
||||
lastSuccess.value = `Acheté : ${productId}`;
|
||||
// Refresh wallet + my entitlements (WS also pushes wallet, this is belt-and-braces).
|
||||
await Promise.all([fetchWallet(), fetchMe(), fetchProducts()]);
|
||||
// Refresh wallet + my entitlements + myPerks (WS also pushes wallet, this is belt-and-braces).
|
||||
await Promise.all([fetchWallet(), fetchMe(), fetchProducts(), refreshMyPerks()]);
|
||||
return true;
|
||||
} catch {
|
||||
lastError.value = 'Réseau indisponible';
|
||||
|
||||
@@ -281,7 +281,7 @@ async function submit(): Promise<void> {
|
||||
}
|
||||
.icon-btn:hover { background: #1c1c2e; }
|
||||
.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; }
|
||||
.input-field {
|
||||
|
||||
@@ -156,17 +156,17 @@ onUnmounted(() => { if (timer) clearInterval(timer); });
|
||||
border: 1px solid #00aaff44; border-radius: 10px; padding: 4px 10px;
|
||||
}
|
||||
.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-right { display: flex; align-items: center; gap: 12px; }
|
||||
.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.free { color: #33ff99; text-shadow: 0 0 10px #00ff6644; }
|
||||
.sh-balance { font-family: 'Courier New', monospace; font-size: 15px; font-weight: bold; color: #ccaa44; }
|
||||
.sh-balance.free { color: #44aa77; }
|
||||
.sh-cr { font-size: 10px; color: #886633; }
|
||||
.sh-topup {
|
||||
background: #1a2a1a; border: 1px solid #33aa55; color: #44dd77;
|
||||
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; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user