Compare commits
2 Commits
3c4a292db2
...
d50f06d65a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d50f06d65a | ||
|
|
d88b71b2c6 |
@@ -145,6 +145,77 @@ const PRODUCTS = [
|
||||
sortOrder: 90,
|
||||
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;
|
||||
|
||||
// ── Ad inventory (the 4 hardcoded joke ads, now real data) ──────────────────
|
||||
|
||||
@@ -163,7 +163,7 @@ export async function purchase(
|
||||
if ((await countActiveEntitlements(ip, product.id)) >= 1)
|
||||
throw new PurchaseError("Déjà débloqué", 409);
|
||||
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;
|
||||
}
|
||||
case "consumable": {
|
||||
@@ -176,6 +176,14 @@ export async function purchase(
|
||||
}
|
||||
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": {
|
||||
// Cosmetic bundle: Style Doré + 1 pet.
|
||||
if ((await countActiveEntitlements(ip, "style-dore")) < 1)
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
@click="$emit('send')"
|
||||
@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" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -27,6 +28,12 @@ defineEmits<{ send: [] }>();
|
||||
const { prefs } = useCustomStyles();
|
||||
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 p = SEND_BUTTON_PRESETS[prefs.sendButton];
|
||||
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:active:not(:disabled) { filter: brightness(0.85); }
|
||||
.send-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.skin-char { font-size: 18px; line-height: 1; }
|
||||
</style>
|
||||
|
||||
@@ -42,11 +42,44 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Couleur de l'IP ────────────────────────────────────────── -->
|
||||
<section class="section" :class="{ locked: !myPerks.elementSkin }">
|
||||
<!-- ── 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.elementSkin" class="lock-badge">🔒 Skin d'éléments requis</span>
|
||||
<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">
|
||||
@@ -55,7 +88,7 @@
|
||||
:key="opt.value"
|
||||
class="style-tile"
|
||||
:class="{ 'style-tile--active': currentIpColor === opt.value }"
|
||||
:disabled="!myPerks.elementSkin"
|
||||
:disabled="!myPerks.ipColors"
|
||||
@click="setIpColor(opt.value)"
|
||||
type="button"
|
||||
>
|
||||
@@ -144,6 +177,7 @@ const ownedPets = computed(() => {
|
||||
});
|
||||
});
|
||||
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 ?? '')
|
||||
);
|
||||
|
||||
@@ -48,11 +48,11 @@
|
||||
<input v-model="url" class="opt-input" type="text" placeholder="URL de destination (optionnel)" />
|
||||
</div>
|
||||
|
||||
<!-- Options : Pet (grille + position) -->
|
||||
<!-- Options : Pet (grille des designs non encore possédés) -->
|
||||
<div v-if="product.kind === 'pet'" class="opts">
|
||||
<div class="pet-grid">
|
||||
<button
|
||||
v-for="d in designs"
|
||||
v-for="d in availableDesigns"
|
||||
:key="d.id"
|
||||
class="pet-cell"
|
||||
:class="{ active: petDesign === d.id }"
|
||||
@@ -60,12 +60,11 @@
|
||||
type="button"
|
||||
>{{ d.char }}</button>
|
||||
</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>
|
||||
|
||||
<!-- Preview : Skin de bouton -->
|
||||
<div v-if="product.kind === 'send-skin'" class="send-skin-preview">
|
||||
<div class="skin-btn-demo">{{ meta.char }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Stock limité -->
|
||||
@@ -107,13 +106,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import type { Product, PurchaseOptions } from '@/composables/useShop';
|
||||
|
||||
const props = defineProps<{
|
||||
product: Product;
|
||||
buying: boolean;
|
||||
owns: (kind: string) => boolean;
|
||||
ownedPetChars: string[];
|
||||
petCount: number;
|
||||
freeMode: boolean;
|
||||
}>();
|
||||
@@ -141,11 +141,19 @@ const url = ref('');
|
||||
|
||||
// Pet
|
||||
const designs = computed(() => meta.value.designs ?? []);
|
||||
const positions = computed<string[]>(() => meta.value.positions ?? ['left', 'right', 'both']);
|
||||
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(() => {
|
||||
if (props.product.id === 'ip-colors') return '🎨';
|
||||
if (props.product.kind === 'send-skin') return meta.value.char ?? '🖱️';
|
||||
switch (props.product.kind) {
|
||||
case 'ad-frame': return '📣';
|
||||
case 'subscription': return '🚫';
|
||||
@@ -180,6 +188,7 @@ const ownedAlready = computed(() => {
|
||||
if (k === 'rich') 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 === 'send-skin') return props.owns(props.product.id);
|
||||
return false;
|
||||
});
|
||||
|
||||
@@ -201,9 +210,6 @@ const stockPct = computed(() =>
|
||||
function fmt(centi: number): string {
|
||||
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 {
|
||||
const options: PurchaseOptions = {};
|
||||
@@ -214,9 +220,8 @@ function onBuy(): void {
|
||||
options.url = url.value || undefined;
|
||||
}
|
||||
if (props.product.kind === 'pet' || props.product.id === 'bundle-cosmetic') {
|
||||
const d = designs.value.find((x: any) => x.id === petDesign.value) ?? designs.value[0];
|
||||
if (d) { options.petDesign = d.id; options.petChar = d.char; }
|
||||
options.petPosition = petPosition.value;
|
||||
const d = availableDesigns.value.find((x: any) => x.id === petDesign.value) ?? availableDesigns.value[0];
|
||||
if (d) { options.petDesign = (d as any).id; options.petChar = (d as any).char; }
|
||||
}
|
||||
emit('buy', props.product.id, options);
|
||||
}
|
||||
@@ -291,6 +296,14 @@ function onBuy(): void {
|
||||
.pet-cell.active { border-color: #8844aa; }
|
||||
.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-bar { height: 6px; background: #1a1a2a; border-radius: 3px; overflow: hidden; }
|
||||
.stock-fill { height: 100%; background: linear-gradient(90deg, #ffaa00, #ff4444); }
|
||||
|
||||
@@ -50,6 +50,7 @@ export const PET_OPTIONS: { value: string; label: string }[] = [
|
||||
|
||||
export interface CustomStylePrefs {
|
||||
sendButton: SendButtonKey;
|
||||
sendSkin: string; // send-skin product id, or '' for default arrow
|
||||
adFrame: AdFrameKey;
|
||||
ipColors: Record<string, string>; // ip → hex or 'auto'
|
||||
ipPets: Record<string, string>; // ip → emoji or ''
|
||||
@@ -57,7 +58,7 @@ export interface CustomStylePrefs {
|
||||
}
|
||||
|
||||
function defaults(): CustomStylePrefs {
|
||||
return { sendButton: 'default', adFrame: 'default', ipColors: {}, ipPets: {}, chatBgUrl: '' };
|
||||
return { sendButton: 'default', sendSkin: '', adFrame: 'default', ipColors: {}, ipPets: {}, chatBgUrl: '' };
|
||||
}
|
||||
|
||||
function load(): CustomStylePrefs {
|
||||
|
||||
@@ -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 === '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 === 'element-skin') p.elementSkin = true; if (e.kind === 'ip-colors') p.ipColors = true;
|
||||
if (e.kind.startsWith('send-skin-')) {
|
||||
let meta2: any = {};
|
||||
try { meta2 = e.metaJson ? JSON.parse(e.metaJson) : {}; } catch {}
|
||||
if (!p.sendSkins) p.sendSkins = [];
|
||||
p.sendSkins.push({ id: e.kind, char: meta2.char ?? '?', label: meta2.label });
|
||||
} if (e.kind === 'rich-htmlcss') p.richHtmlcss = true;
|
||||
if (e.kind === 'rich-js') p.richJs = true;
|
||||
if (e.kind === 'no-file-limit') p.noFileLimit = true;
|
||||
if (e.kind === 'audio-alert') p.audioAlert = true;
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface Perks {
|
||||
elementSkin?: boolean;
|
||||
richHtmlcss?: boolean;
|
||||
richJs?: boolean;
|
||||
ipColors?: boolean;
|
||||
sendSkins?: { id: string; char: string; label?: string }[];
|
||||
noFileLimit?: boolean;
|
||||
audioAlert?: boolean;
|
||||
}
|
||||
|
||||
@@ -81,6 +81,16 @@ export function useShop() {
|
||||
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> {
|
||||
buying.value = productId;
|
||||
lastError.value = null;
|
||||
@@ -119,6 +129,7 @@ export function useShop() {
|
||||
fetchMe,
|
||||
owns,
|
||||
petCount,
|
||||
ownedPetChars,
|
||||
purchase,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
@@ -10,4 +43,5 @@ body,
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #080808;
|
||||
font-family: 'Lato', sans-serif;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
:buying="buying === p.id"
|
||||
:owns="owns"
|
||||
:pet-count="petCount()"
|
||||
:owned-pet-chars="ownedPetChars()"
|
||||
:free-mode="freeMode"
|
||||
@buy="onBuy"
|
||||
@go-perso="activeCat = 'perso'"
|
||||
@@ -78,7 +79,7 @@ import { useWallet } from '@/composables/useWallet';
|
||||
import ProductCard from '@/components/shop/ProductCard.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 categories = [
|
||||
@@ -92,11 +93,19 @@ const categories = [
|
||||
];
|
||||
const activeCat = ref('all');
|
||||
|
||||
const visibleProducts = computed(() =>
|
||||
activeCat.value === 'all'
|
||||
const visibleProducts = computed(() => {
|
||||
const chars = ownedPetChars();
|
||||
const base = activeCat.value === 'all'
|
||||
? 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> {
|
||||
await purchase(productId, options);
|
||||
|
||||
Reference in New Issue
Block a user