refactor(shop): découpe MesPersos en sous-sections + nettoyage ProductCard
All checks were successful
Deploy XIP / deploy (push) Successful in 35s
All checks were successful
Deploy XIP / deploy (push) Successful in 35s
- MesPersos.vue (347L) éclaté en 5 sous-composants autonomes sous shop/persos/ (BgPrefsSection, SendButtonPrefsSection, SendSkinPrefsSection, IpColorPrefsSection, PetsPrefsSection). MesPersos n'est plus qu'un wrapper. - CSS partagé des sections déplacé en classes globales .pf-* dans style.css (plus de duplication entre les sections). - ProductCard : metaJson typé via parseMeta<ProductMeta>(), suppression des casts `any` (find/designs) — comportement identique. - vue-tsc --noEmit : 0 erreur. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,191 +1,21 @@
|
|||||||
<!-- Mes Personnalisations — visible depuis le shop, onglet "Mes Persos" -->
|
<!-- Mes Personnalisations — onglet "Mes Persos" du shop.
|
||||||
|
Assemble les sections de préférences (chacune autonome, lit ses composables). -->
|
||||||
<template>
|
<template>
|
||||||
<div class="persos">
|
<div class="persos">
|
||||||
|
<BgPrefsSection />
|
||||||
<!-- ── Image de fond du chat ─────────────────────────────────── -->
|
<SendButtonPrefsSection />
|
||||||
<section class="section">
|
<SendSkinPrefsSection />
|
||||||
<h2 class="section-title">🖼️ Fond du chat</h2>
|
<IpColorPrefsSection />
|
||||||
<p class="section-sub">URL d'une image (jpg, png, gif, webp…) ou laisse vide pour le fond par défaut.</p>
|
<PetsPrefsSection />
|
||||||
<div class="bg-row">
|
|
||||||
<input
|
|
||||||
v-model="bgDraft"
|
|
||||||
class="bg-input"
|
|
||||||
type="text"
|
|
||||||
placeholder="https://example.com/image.jpg"
|
|
||||||
@keydown.enter="applyBg"
|
|
||||||
/>
|
|
||||||
<button class="btn-apply" @click="applyBg" type="button">Appliquer</button>
|
|
||||||
<button v-if="prefs.chatBgUrl" class="btn-reset" @click="resetBg" type="button">✕ Retirer</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="prefs.chatBgUrl" class="bg-preview" :style="{ backgroundImage: `url(${prefs.chatBgUrl})` }" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ── Bouton d'envoi ─────────────────────────────────────────── -->
|
|
||||||
<section class="section" :class="{ locked: !myPerks.elementSkin }">
|
|
||||||
<h2 class="section-title">
|
|
||||||
➤ Bouton d'envoi
|
|
||||||
<span v-if="!myPerks.elementSkin" class="lock-badge">🔒 Skin d'éléments requis</span>
|
|
||||||
</h2>
|
|
||||||
<div class="style-grid">
|
|
||||||
<button
|
|
||||||
v-for="[k, p] in Object.entries(SEND_BUTTON_PRESETS)"
|
|
||||||
:key="k"
|
|
||||||
class="style-tile"
|
|
||||||
:class="{ 'style-tile--active': prefs.sendButton === k }"
|
|
||||||
:disabled="!myPerks.elementSkin"
|
|
||||||
@click="prefs.sendButton = k as SendButtonKey"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span class="style-swatch" :style="{ background: p.bg, color: p.color, borderRadius: p.radius }">➤</span>
|
|
||||||
<span class="style-label">{{ p.label }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ── Skin du bouton d'envoi ───────────────────────────────────── -->
|
|
||||||
<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">
|
|
||||||
🎨 Couleur de mon IP
|
|
||||||
<span v-if="!myPerks.ipColors" class="lock-badge">🔒 Palette IP requise</span>
|
|
||||||
</h2>
|
|
||||||
<p v-if="myIp" class="section-sub">IP : <code class="code-ip" :style="ipPreviewStyle">{{ myIp }}</code></p>
|
|
||||||
<div class="style-grid">
|
|
||||||
<button
|
|
||||||
v-for="opt in IP_COLOR_OPTIONS"
|
|
||||||
:key="opt.value"
|
|
||||||
class="style-tile"
|
|
||||||
:class="{ 'style-tile--active': currentIpColor === opt.value }"
|
|
||||||
:disabled="!myPerks.ipColors"
|
|
||||||
@click="setIpColor(opt.value)"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span v-if="opt.swatch" class="color-dot" :style="{ background: opt.swatch }" />
|
|
||||||
<span v-else class="color-dot color-dot--auto" />
|
|
||||||
<span class="style-label">{{ opt.label }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ── Pets ───────────────────────────────────────────────────── -->
|
|
||||||
<section class="section" :class="{ locked: !hasPets }">
|
|
||||||
<h2 class="section-title">
|
|
||||||
✨ Mes pets
|
|
||||||
<span v-if="!hasPets" class="lock-badge">Achetez un Pet dans le shop</span>
|
|
||||||
</h2>
|
|
||||||
<template v-if="hasPets">
|
|
||||||
<div class="pet-grid">
|
|
||||||
<button
|
|
||||||
v-for="pet in ownedPets"
|
|
||||||
:key="pet.char"
|
|
||||||
class="pet-cell"
|
|
||||||
:class="{ 'pet-cell--active': activePet === pet.char }"
|
|
||||||
@click="togglePet(pet.char)"
|
|
||||||
type="button"
|
|
||||||
>{{ pet.char }}</button>
|
|
||||||
<button
|
|
||||||
class="pet-cell pet-cell--none"
|
|
||||||
:class="{ 'pet-cell--active': activePet === '' }"
|
|
||||||
@click="togglePet('')"
|
|
||||||
type="button"
|
|
||||||
>✕ Aucun</button>
|
|
||||||
</div>
|
|
||||||
<p class="section-sub" style="margin-top:6px">
|
|
||||||
Actif : <strong>{{ activePet || 'aucun' }}</strong>
|
|
||||||
— s'affiche à gauche de ton IP dans le chat.
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<p v-else class="section-sub">Aucun pet possédé pour l'instant.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue';
|
import BgPrefsSection from './persos/BgPrefsSection.vue';
|
||||||
import { useCustomStyles, SEND_BUTTON_PRESETS, IP_COLOR_OPTIONS, type SendButtonKey } from '@/composables/useCustomStyles';
|
import SendButtonPrefsSection from './persos/SendButtonPrefsSection.vue';
|
||||||
import { useMyPerks } from '@/composables/useMessages';
|
import SendSkinPrefsSection from './persos/SendSkinPrefsSection.vue';
|
||||||
import { getIpColorWithPerks, getIpGlow } from '@/composables/ipColor';
|
import IpColorPrefsSection from './persos/IpColorPrefsSection.vue';
|
||||||
import { useWallet } from '@/composables/useWallet';
|
import PetsPrefsSection from './persos/PetsPrefsSection.vue';
|
||||||
|
|
||||||
const { prefs } = useCustomStyles();
|
|
||||||
const { myPerks } = useMyPerks();
|
|
||||||
const { ip: myIp } = useWallet();
|
|
||||||
|
|
||||||
// ── Background ──────────────────────────────────────────────────────────────
|
|
||||||
const bgDraft = ref(prefs.chatBgUrl);
|
|
||||||
watch(() => prefs.chatBgUrl, (v) => { bgDraft.value = v; });
|
|
||||||
|
|
||||||
function applyBg(): void { prefs.chatBgUrl = bgDraft.value.trim(); }
|
|
||||||
function resetBg(): void { prefs.chatBgUrl = ''; bgDraft.value = ''; }
|
|
||||||
|
|
||||||
// ── IP color ────────────────────────────────────────────────────────────────
|
|
||||||
const currentIpColor = computed(() => prefs.ipColors[myIp.value] ?? 'auto');
|
|
||||||
|
|
||||||
function setIpColor(value: string): void {
|
|
||||||
if (!myIp.value) return;
|
|
||||||
prefs.ipColors[myIp.value] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ipPreviewStyle = computed(() => {
|
|
||||||
if (!myIp.value) return {};
|
|
||||||
const color = currentIpColor.value === 'auto'
|
|
||||||
? getIpColorWithPerks(myIp.value, myPerks.value)
|
|
||||||
: currentIpColor.value;
|
|
||||||
return { color, textShadow: getIpGlow(color) };
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Pets ────────────────────────────────────────────────────────────────────
|
|
||||||
const ownedPets = computed(() => {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return (myPerks.value.pets ?? []).filter((p) => {
|
|
||||||
if (seen.has(p.char)) return false;
|
|
||||||
seen.add(p.char);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const hasPets = computed(() => ownedPets.value.length > 0);
|
|
||||||
const hasSendSkins = computed(() => (myPerks.value.sendSkins?.length ?? 0) > 0);
|
|
||||||
const activePet = computed(() =>
|
|
||||||
myIp.value && myIp.value in prefs.ipPets ? prefs.ipPets[myIp.value] : (ownedPets.value[0]?.char ?? '')
|
|
||||||
);
|
|
||||||
|
|
||||||
function togglePet(char: string): void {
|
|
||||||
if (!myIp.value) return;
|
|
||||||
prefs.ipPets[myIp.value] = char;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -196,152 +26,4 @@ function togglePet(char: string): void {
|
|||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
|
||||||
background: #101018;
|
|
||||||
border: 1px solid #20203a;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 18px 20px;
|
|
||||||
}
|
|
||||||
.section.locked {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #ccccee;
|
|
||||||
margin: 0 0 6px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.section-sub {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #5a5a80;
|
|
||||||
margin: 0 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lock-badge {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: normal;
|
|
||||||
color: #886644;
|
|
||||||
background: #1a1408;
|
|
||||||
border: 1px solid #44330066;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Background */
|
|
||||||
.bg-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
.bg-input {
|
|
||||||
flex: 1;
|
|
||||||
background: #141420;
|
|
||||||
border: 1px solid #222234;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #aaaacc;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.bg-input:focus { border-color: #333355; }
|
|
||||||
.btn-apply {
|
|
||||||
background: #1a2a1a; border: 1px solid #33aa55; color: #44dd77;
|
|
||||||
font-size: 12px; font-weight: bold; padding: 7px 14px; border-radius: 14px; cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn-apply:hover { background: #234a23; }
|
|
||||||
.btn-reset {
|
|
||||||
background: #2a1010; border: 1px solid #882222; color: #ff6655;
|
|
||||||
font-size: 11px; padding: 7px 12px; border-radius: 14px; cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn-reset:hover { background: #3a1818; }
|
|
||||||
|
|
||||||
.bg-preview {
|
|
||||||
width: 100%;
|
|
||||||
height: 80px;
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid #222234;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Style tiles */
|
|
||||||
.style-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.style-tile {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
background: #141420;
|
|
||||||
border: 1px solid #222234;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.1s, background 0.1s;
|
|
||||||
}
|
|
||||||
.style-tile:hover:not(:disabled) { background: #1a1a2e; border-color: #333355; }
|
|
||||||
.style-tile--active { border-color: #00ddff; background: #0a1a20; }
|
|
||||||
.style-tile:disabled { cursor: not-allowed; opacity: 0.5; }
|
|
||||||
|
|
||||||
.style-swatch {
|
|
||||||
width: 34px; height: 34px;
|
|
||||||
border-radius: inherit;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
font-size: 14px; font-weight: bold;
|
|
||||||
border: 1px solid #ffffff10;
|
|
||||||
}
|
|
||||||
.style-label {
|
|
||||||
font-size: 10px;
|
|
||||||
color: #8888aa;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.style-tile--active .style-label { color: #00ddff; }
|
|
||||||
|
|
||||||
/* Color dots */
|
|
||||||
.color-dot {
|
|
||||||
width: 20px; height: 20px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1px solid #ffffff22;
|
|
||||||
}
|
|
||||||
.color-dot--auto {
|
|
||||||
background: conic-gradient(#00ddff, #ff00cc, #00ee77, #ffdd44, #00ddff);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* IP code */
|
|
||||||
.code-ip {
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pet grid */
|
|
||||||
.pet-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.pet-cell {
|
|
||||||
width: 42px; height: 42px;
|
|
||||||
background: #141420;
|
|
||||||
border: 1px solid #222234;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 20px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.1s, background 0.1s;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
}
|
|
||||||
.pet-cell:hover { background: #1a1a2e; border-color: #333355; }
|
|
||||||
.pet-cell--active { border-color: #00ddff; background: #0a1a20; }
|
|
||||||
.pet-cell--none { font-size: 11px; color: #666; width: auto; padding: 0 10px; }
|
|
||||||
.pet-cell--none.pet-cell--active { color: #00ddff; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -108,6 +108,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import type { Product, PurchaseOptions } from '@/composables/useShop';
|
import type { Product, PurchaseOptions } from '@/composables/useShop';
|
||||||
|
import { parseMeta, type ProductMeta } from '@/composables/useMeta';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
product: Product;
|
product: Product;
|
||||||
@@ -123,10 +124,7 @@ const emit = defineEmits<{
|
|||||||
goPerso: [];
|
goPerso: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const meta = computed<any>(() => {
|
const meta = computed(() => parseMeta<ProductMeta>(props.product.metaJson));
|
||||||
try { return props.product.metaJson ? JSON.parse(props.product.metaJson) : {}; }
|
|
||||||
catch { return {}; }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Subscription
|
// Subscription
|
||||||
const plans = computed(() => meta.value.plans ?? []);
|
const plans = computed(() => meta.value.plans ?? []);
|
||||||
@@ -143,11 +141,11 @@ const url = ref('');
|
|||||||
const designs = computed(() => meta.value.designs ?? []);
|
const designs = computed(() => meta.value.designs ?? []);
|
||||||
const petDesign = ref<string>('');
|
const petDesign = ref<string>('');
|
||||||
const availableDesigns = computed(() =>
|
const availableDesigns = computed(() =>
|
||||||
designs.value.filter((d: any) => !props.ownedPetChars.includes(d.char))
|
designs.value.filter((d) => !props.ownedPetChars.includes(d.char))
|
||||||
);
|
);
|
||||||
watch(availableDesigns, (ds) => {
|
watch(availableDesigns, (ds) => {
|
||||||
if (ds.length > 0 && !ds.find((d: any) => d.id === petDesign.value)) {
|
if (ds.length > 0 && !ds.find((d) => d.id === petDesign.value)) {
|
||||||
petDesign.value = (ds[0] as any).id;
|
petDesign.value = ds[0].id;
|
||||||
}
|
}
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
@@ -169,12 +167,12 @@ const icon = computed(() => {
|
|||||||
const effectivePrice = computed(() => {
|
const effectivePrice = computed(() => {
|
||||||
let price = props.product.promoPrice ?? props.product.basePrice;
|
let price = props.product.promoPrice ?? props.product.basePrice;
|
||||||
if (props.product.kind === 'subscription') {
|
if (props.product.kind === 'subscription') {
|
||||||
const p = plans.value.find((x: any) => x.id === plan.value);
|
const p = plans.value.find((x) => x.id === plan.value);
|
||||||
if (p) price = p.price;
|
if (p) price = p.price;
|
||||||
}
|
}
|
||||||
if (props.product.kind === 'ad-frame') {
|
if (props.product.kind === 'ad-frame') {
|
||||||
const d = durations.value.find((x: any) => x.days === durationDays.value);
|
const d = durations.value.find((x) => x.days === durationDays.value);
|
||||||
const f = formats.value.find((x: any) => x.id === format.value);
|
const f = formats.value.find((x) => x.id === format.value);
|
||||||
price += (d?.extra ?? 0) + (f?.extra ?? 0);
|
price += (d?.extra ?? 0) + (f?.extra ?? 0);
|
||||||
}
|
}
|
||||||
return price;
|
return price;
|
||||||
@@ -220,8 +218,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 = availableDesigns.value.find((x: any) => x.id === petDesign.value) ?? availableDesigns.value[0];
|
const d = availableDesigns.value.find((x) => x.id === petDesign.value) ?? availableDesigns.value[0];
|
||||||
if (d) { options.petDesign = (d as any).id; options.petChar = (d as any).char; }
|
if (d) { options.petDesign = d.id; options.petChar = d.char; }
|
||||||
}
|
}
|
||||||
emit('buy', props.product.id, options);
|
emit('buy', props.product.id, options);
|
||||||
}
|
}
|
||||||
|
|||||||
55
frontend/src/components/shop/persos/BgPrefsSection.vue
Normal file
55
frontend/src/components/shop/persos/BgPrefsSection.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<!-- Mes Persos › Fond du chat (image de fond personnalisée, viewer-side) -->
|
||||||
|
<template>
|
||||||
|
<section class="pf-section">
|
||||||
|
<h2 class="pf-title">🖼️ Fond du chat</h2>
|
||||||
|
<p class="pf-sub">URL d'une image (jpg, png, gif, webp…) ou laisse vide pour le fond par défaut.</p>
|
||||||
|
<div class="bg-row">
|
||||||
|
<input
|
||||||
|
v-model="bgDraft"
|
||||||
|
class="bg-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="https://example.com/image.jpg"
|
||||||
|
@keydown.enter="applyBg"
|
||||||
|
/>
|
||||||
|
<button class="btn-apply" @click="applyBg" type="button">Appliquer</button>
|
||||||
|
<button v-if="prefs.chatBgUrl" class="btn-reset" @click="resetBg" type="button">✕ Retirer</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="prefs.chatBgUrl" class="bg-preview" :style="{ backgroundImage: `url(${prefs.chatBgUrl})` }" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { useCustomStyles } from '@/composables/useCustomStyles';
|
||||||
|
|
||||||
|
const { prefs } = useCustomStyles();
|
||||||
|
|
||||||
|
const bgDraft = ref(prefs.chatBgUrl);
|
||||||
|
watch(() => prefs.chatBgUrl, (v) => { bgDraft.value = v; });
|
||||||
|
|
||||||
|
function applyBg(): void { prefs.chatBgUrl = bgDraft.value.trim(); }
|
||||||
|
function resetBg(): void { prefs.chatBgUrl = ''; bgDraft.value = ''; }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bg-row { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
|
||||||
|
.bg-input {
|
||||||
|
flex: 1; background: #141420; border: 1px solid #222234; border-radius: 6px;
|
||||||
|
color: #aaaacc; font-family: Arial, sans-serif; font-size: 12px; padding: 8px 12px; outline: none;
|
||||||
|
}
|
||||||
|
.bg-input:focus { border-color: #333355; }
|
||||||
|
.btn-apply {
|
||||||
|
background: #1a2a1a; border: 1px solid #33aa55; color: #44dd77;
|
||||||
|
font-size: 12px; font-weight: bold; padding: 7px 14px; border-radius: 14px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-apply:hover { background: #234a23; }
|
||||||
|
.btn-reset {
|
||||||
|
background: #2a1010; border: 1px solid #882222; color: #ff6655;
|
||||||
|
font-size: 11px; padding: 7px 12px; border-radius: 14px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-reset:hover { background: #3a1818; }
|
||||||
|
.bg-preview {
|
||||||
|
width: 100%; height: 80px; background-size: cover; background-position: center;
|
||||||
|
border-radius: 6px; border: 1px solid #222234;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
56
frontend/src/components/shop/persos/IpColorPrefsSection.vue
Normal file
56
frontend/src/components/shop/persos/IpColorPrefsSection.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<!-- Mes Persos › Couleur de mon IP (viewer-side, nécessite la Palette IP) -->
|
||||||
|
<template>
|
||||||
|
<section class="pf-section" :class="{ 'pf-locked': !myPerks.ipColors }">
|
||||||
|
<h2 class="pf-title">
|
||||||
|
🎨 Couleur de mon IP
|
||||||
|
<span v-if="!myPerks.ipColors" class="pf-lock">🔒 Palette IP requise</span>
|
||||||
|
</h2>
|
||||||
|
<p v-if="myIp" class="pf-sub">IP : <code class="code-ip" :style="ipPreviewStyle">{{ myIp }}</code></p>
|
||||||
|
<div class="pf-grid">
|
||||||
|
<button
|
||||||
|
v-for="opt in IP_COLOR_OPTIONS"
|
||||||
|
:key="opt.value"
|
||||||
|
class="pf-tile"
|
||||||
|
:class="{ 'pf-tile--active': currentIpColor === opt.value }"
|
||||||
|
:disabled="!myPerks.ipColors"
|
||||||
|
@click="setIpColor(opt.value)"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span v-if="opt.swatch" class="pf-dot" :style="{ background: opt.swatch }" />
|
||||||
|
<span v-else class="pf-dot pf-dot--auto" />
|
||||||
|
<span class="pf-label">{{ opt.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useCustomStyles, IP_COLOR_OPTIONS } from '@/composables/useCustomStyles';
|
||||||
|
import { useMyPerks } from '@/composables/useMessages';
|
||||||
|
import { useWallet } from '@/composables/useWallet';
|
||||||
|
import { getIpColorWithPerks, getIpGlow } from '@/composables/ipColor';
|
||||||
|
|
||||||
|
const { prefs } = useCustomStyles();
|
||||||
|
const { myPerks } = useMyPerks();
|
||||||
|
const { ip: myIp } = useWallet();
|
||||||
|
|
||||||
|
const currentIpColor = computed(() => prefs.ipColors[myIp.value] ?? 'auto');
|
||||||
|
|
||||||
|
function setIpColor(value: string): void {
|
||||||
|
if (!myIp.value) return;
|
||||||
|
prefs.ipColors[myIp.value] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipPreviewStyle = computed(() => {
|
||||||
|
if (!myIp.value) return {};
|
||||||
|
const color = currentIpColor.value === 'auto'
|
||||||
|
? getIpColorWithPerks(myIp.value, myPerks.value)
|
||||||
|
: currentIpColor.value;
|
||||||
|
return { color, textShadow: getIpGlow(color) };
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.code-ip { font-family: 'Courier New', monospace; font-size: 12px; font-weight: bold; }
|
||||||
|
</style>
|
||||||
74
frontend/src/components/shop/persos/PetsPrefsSection.vue
Normal file
74
frontend/src/components/shop/persos/PetsPrefsSection.vue
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<!-- Mes Persos › Pet actif affiché à gauche de l'IP (parmi les pets possédés) -->
|
||||||
|
<template>
|
||||||
|
<section class="pf-section" :class="{ 'pf-locked': !hasPets }">
|
||||||
|
<h2 class="pf-title">
|
||||||
|
✨ Mes pets
|
||||||
|
<span v-if="!hasPets" class="pf-lock">Achetez un Pet dans le shop</span>
|
||||||
|
</h2>
|
||||||
|
<template v-if="hasPets">
|
||||||
|
<div class="pf-grid">
|
||||||
|
<button
|
||||||
|
v-for="pet in ownedPets"
|
||||||
|
:key="pet.char"
|
||||||
|
class="pet-cell"
|
||||||
|
:class="{ 'pet-cell--active': activePet === pet.char }"
|
||||||
|
@click="togglePet(pet.char)"
|
||||||
|
type="button"
|
||||||
|
>{{ pet.char }}</button>
|
||||||
|
<button
|
||||||
|
class="pet-cell pet-cell--none"
|
||||||
|
:class="{ 'pet-cell--active': activePet === '' }"
|
||||||
|
@click="togglePet('')"
|
||||||
|
type="button"
|
||||||
|
>✕ Aucun</button>
|
||||||
|
</div>
|
||||||
|
<p class="pf-sub" style="margin-top:6px">
|
||||||
|
Actif : <strong>{{ activePet || 'aucun' }}</strong>
|
||||||
|
— s'affiche à gauche de ton IP dans le chat.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<p v-else class="pf-sub">Aucun pet possédé pour l'instant.</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useCustomStyles } from '@/composables/useCustomStyles';
|
||||||
|
import { useMyPerks } from '@/composables/useMessages';
|
||||||
|
import { useWallet } from '@/composables/useWallet';
|
||||||
|
|
||||||
|
const { prefs } = useCustomStyles();
|
||||||
|
const { myPerks } = useMyPerks();
|
||||||
|
const { ip: myIp } = useWallet();
|
||||||
|
|
||||||
|
const ownedPets = computed(() => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return (myPerks.value.pets ?? []).filter((p) => {
|
||||||
|
if (seen.has(p.char)) return false;
|
||||||
|
seen.add(p.char);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const hasPets = computed(() => ownedPets.value.length > 0);
|
||||||
|
const activePet = computed(() =>
|
||||||
|
myIp.value && myIp.value in prefs.ipPets ? prefs.ipPets[myIp.value] : (ownedPets.value[0]?.char ?? '')
|
||||||
|
);
|
||||||
|
|
||||||
|
function togglePet(char: string): void {
|
||||||
|
if (!myIp.value) return;
|
||||||
|
prefs.ipPets[myIp.value] = char;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pet-cell {
|
||||||
|
width: 42px; height: 42px;
|
||||||
|
background: #141420; border: 1px solid #222234; border-radius: 8px;
|
||||||
|
font-size: 20px; cursor: pointer; transition: border-color 0.1s, background 0.1s;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.pet-cell:hover { background: #1a1a2e; border-color: #333355; }
|
||||||
|
.pet-cell--active { border-color: #00ddff; background: #0a1a20; }
|
||||||
|
.pet-cell--none { font-size: 11px; color: #666; width: auto; padding: 0 10px; }
|
||||||
|
.pet-cell--none.pet-cell--active { color: #00ddff; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!-- Mes Persos › Couleur du bouton d'envoi (preset, nécessite le skin d'éléments) -->
|
||||||
|
<template>
|
||||||
|
<section class="pf-section" :class="{ 'pf-locked': !myPerks.elementSkin }">
|
||||||
|
<h2 class="pf-title">
|
||||||
|
➤ Bouton d'envoi
|
||||||
|
<span v-if="!myPerks.elementSkin" class="pf-lock">🔒 Skin d'éléments requis</span>
|
||||||
|
</h2>
|
||||||
|
<div class="pf-grid">
|
||||||
|
<button
|
||||||
|
v-for="[k, p] in presetEntries"
|
||||||
|
:key="k"
|
||||||
|
class="pf-tile"
|
||||||
|
:class="{ 'pf-tile--active': prefs.sendButton === k }"
|
||||||
|
:disabled="!myPerks.elementSkin"
|
||||||
|
@click="prefs.sendButton = k as SendButtonKey"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="pf-swatch" :style="{ background: p.bg, color: p.color, borderRadius: p.radius }">➤</span>
|
||||||
|
<span class="pf-label">{{ p.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useCustomStyles, SEND_BUTTON_PRESETS, type SendButtonKey } from '@/composables/useCustomStyles';
|
||||||
|
import { useMyPerks } from '@/composables/useMessages';
|
||||||
|
|
||||||
|
const { prefs } = useCustomStyles();
|
||||||
|
const { myPerks } = useMyPerks();
|
||||||
|
|
||||||
|
const presetEntries = Object.entries(SEND_BUTTON_PRESETS);
|
||||||
|
</script>
|
||||||
45
frontend/src/components/shop/persos/SendSkinPrefsSection.vue
Normal file
45
frontend/src/components/shop/persos/SendSkinPrefsSection.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<!-- Mes Persos › Skin (emoji) du bouton d'envoi, parmi les skins possédés -->
|
||||||
|
<template>
|
||||||
|
<section class="pf-section" :class="{ 'pf-locked': !hasSendSkins }">
|
||||||
|
<h2 class="pf-title">
|
||||||
|
🖱️ Skin du bouton d'envoi
|
||||||
|
<span v-if="!hasSendSkins" class="pf-lock">Achetez un skin dans le shop</span>
|
||||||
|
</h2>
|
||||||
|
<template v-if="hasSendSkins">
|
||||||
|
<div class="pf-grid">
|
||||||
|
<button
|
||||||
|
class="pf-tile"
|
||||||
|
:class="{ 'pf-tile--active': prefs.sendSkin === '' }"
|
||||||
|
@click="prefs.sendSkin = ''"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="pf-swatch" style="font-size:14px">►</span>
|
||||||
|
<span class="pf-label">Défaut</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="s in myPerks.sendSkins"
|
||||||
|
:key="s.id"
|
||||||
|
class="pf-tile"
|
||||||
|
:class="{ 'pf-tile--active': prefs.sendSkin === s.id }"
|
||||||
|
@click="prefs.sendSkin = s.id"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="pf-swatch" style="font-size:20px">{{ s.char }}</span>
|
||||||
|
<span class="pf-label">{{ s.label ?? s.id.replace('send-skin-', '') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p v-else class="pf-sub">Aucun skin possédé pour l'instant.</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useCustomStyles } from '@/composables/useCustomStyles';
|
||||||
|
import { useMyPerks } from '@/composables/useMessages';
|
||||||
|
|
||||||
|
const { prefs } = useCustomStyles();
|
||||||
|
const { myPerks } = useMyPerks();
|
||||||
|
|
||||||
|
const hasSendSkins = computed(() => (myPerks.value.sendSkins?.length ?? 0) > 0);
|
||||||
|
</script>
|
||||||
@@ -76,3 +76,41 @@ body,
|
|||||||
background: var(--xip-app-bg);
|
background: var(--xip-app-bg);
|
||||||
font-family: 'Lato', sans-serif;
|
font-family: 'Lato', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Styles partagés des sections « Mes Persos » (shop/persos/*) ──
|
||||||
|
Globaux (non scopés) pour être réutilisés par chaque sous-section sans
|
||||||
|
dupliquer le CSS. Préfixe .pf- (persos-form) pour éviter les collisions. */
|
||||||
|
.pf-section {
|
||||||
|
background: #101018;
|
||||||
|
border: 1px solid #20203a;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
.pf-section.pf-locked { opacity: 0.6; }
|
||||||
|
.pf-title {
|
||||||
|
font-size: 14px; font-weight: bold; color: #ccccee;
|
||||||
|
margin: 0 0 6px; display: flex; align-items: center; gap: 10px;
|
||||||
|
}
|
||||||
|
.pf-sub { font-size: 11px; color: #5a5a80; margin: 0 0 12px; }
|
||||||
|
.pf-lock {
|
||||||
|
font-size: 10px; font-weight: normal; color: #886644;
|
||||||
|
background: #1a1408; border: 1px solid #44330066; border-radius: 8px; padding: 2px 8px;
|
||||||
|
}
|
||||||
|
.pf-grid { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.pf-tile {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||||
|
background: #141420; border: 1px solid #222234; border-radius: 8px;
|
||||||
|
padding: 10px 14px; cursor: pointer; transition: border-color 0.1s, background 0.1s;
|
||||||
|
}
|
||||||
|
.pf-tile:hover:not(:disabled) { background: #1a1a2e; border-color: #333355; }
|
||||||
|
.pf-tile--active { border-color: #00ddff; background: #0a1a20; }
|
||||||
|
.pf-tile:disabled { cursor: not-allowed; opacity: 0.5; }
|
||||||
|
.pf-swatch {
|
||||||
|
width: 34px; height: 34px; border-radius: inherit;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 14px; font-weight: bold; border: 1px solid #ffffff10;
|
||||||
|
}
|
||||||
|
.pf-label { font-size: 10px; color: #8888aa; white-space: nowrap; }
|
||||||
|
.pf-tile--active .pf-label { color: #00ddff; }
|
||||||
|
.pf-dot { width: 20px; height: 20px; border-radius: 50%; border: 1px solid #ffffff22; }
|
||||||
|
.pf-dot--auto { background: conic-gradient(#00ddff, #ff00cc, #00ee77, #ffdd44, #00ddff); }
|
||||||
|
|||||||
Reference in New Issue
Block a user