100 gated gifts (level/rating-locked) + requirement system
Adds ~100 new purchasable gifts that are LOCKED until a level/rating gate is met, then buyable with coins — value scales with the gate: - 45 gift avatars (types.ts), 35 gift titles + 20 gift card backs (gamification.ts), all reusing existing renderers. Tier (1-5) encoded in the id (-t<n>-). - Gate model: GIFT_TIERS (shared) → reqLevel/reqRating on AvatarDef/TitleDef/ CardBackDef + ShopItem. Tiers: t1 free, t2 Lv10, t3 Lv20, t4 Lv35, t5 Rating1700. - Shop UI: locked cards dim + show the requirement (Lock + "Level 20"), buy disabled until met; mock buyItem enforces it offline. - Server enforces generically — ProfileService parses the tier from the id and checks the player's level/rating (no 100-entry mirror). Mirrors GIFT_TIERS. - i18n shop.reqLevel/reqRating (fa+en). Verified: tsc + sim + next build + dotnet build all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -125,10 +125,28 @@ public class ProfileService
|
||||
["xp3"] = (8000, 1500),
|
||||
};
|
||||
|
||||
// Gated gifts encode their tier in the id (`-t<n>-`); the gate is derived from
|
||||
// the tier so the server enforces it without a 100-entry catalog mirror.
|
||||
// Mirrors GIFT_TIERS in src/lib/online/types.ts.
|
||||
private static readonly (int Level, int Rating)[] GiftGate =
|
||||
{ (0, 0), (0, 0), (10, 0), (20, 0), (35, 0), (0, 1700) }; // index = tier (1..5)
|
||||
|
||||
private static (int Level, int Rating) GiftGateFor(string id)
|
||||
{
|
||||
var m = System.Text.RegularExpressions.Regex.Match(id, @"-t(\d)-");
|
||||
if (m.Success && int.TryParse(m.Groups[1].Value, out var tier) && tier >= 1 && tier <= 5)
|
||||
return GiftGate[tier];
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
public async Task<(bool ok, ProfileDto? profile, string error)> ShopBuy(string uid, string kind, string id, int price)
|
||||
{
|
||||
var p = await GetOrCreate(uid, null);
|
||||
|
||||
// Gated gift: locked until the player meets the tier's level/rating gate.
|
||||
var gate = GiftGateFor(id);
|
||||
if (p.Level < gate.Level || p.Rating < gate.Rating) return (false, p, "locked");
|
||||
|
||||
// XP packs are consumable (grant XP, may level up) — not added to an owned list.
|
||||
if (kind == "xp")
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Check, Coins, Sparkles, X } from "lucide-react";
|
||||
import { Check, Coins, Lock, Sparkles, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
|
||||
import { Sticker } from "@/components/online/Sticker";
|
||||
@@ -94,6 +94,13 @@ export function ShopScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// Requirement gate: returns a label while LOCKED, else null.
|
||||
const lockLabel = (item: ShopItem): string | null => {
|
||||
if (item.reqLevel && profile.level < item.reqLevel) return `${t("shop.reqLevel")} ${item.reqLevel}`;
|
||||
if (item.reqRating && profile.rating < item.reqRating) return `${t("shop.reqRating")} ${item.reqRating}`;
|
||||
return null;
|
||||
};
|
||||
|
||||
const buy = async (item: ShopItem) => {
|
||||
const before = profile;
|
||||
const res = await getService().buyItem(item.id);
|
||||
@@ -179,7 +186,7 @@ export function ShopScreen() {
|
||||
<Section key={sec.kind} title={sec.title} hint={sec.hint}>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{list.map((item) => (
|
||||
<ItemCard key={item.id} item={item} owned={owns(item)} onOpen={() => setDetail(item)} />
|
||||
<ItemCard key={item.id} item={item} owned={owns(item)} reqLabel={lockLabel(item)} onOpen={() => setDetail(item)} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
@@ -192,6 +199,7 @@ export function ShopScreen() {
|
||||
item={detail}
|
||||
owned={owns(detail)}
|
||||
coins={profile.coins}
|
||||
reqLabel={lockLabel(detail)}
|
||||
onBuy={() => buy(detail)}
|
||||
onClose={() => setDetail(null)}
|
||||
/>
|
||||
@@ -212,10 +220,11 @@ function Section({ title, hint, children }: { title: string; hint?: string; chil
|
||||
);
|
||||
}
|
||||
|
||||
function ItemCard({ item, owned, onOpen }: { item: ShopItem; owned: boolean; onOpen: () => void }) {
|
||||
function ItemCard({ item, owned, reqLabel, onOpen }: { item: ShopItem; owned: boolean; reqLabel: string | null; onOpen: () => void }) {
|
||||
const { locale, t } = useI18n();
|
||||
const count = item.contents?.length;
|
||||
const luxury = item.price >= 2000; // premium "luxury" tier
|
||||
const locked = !owned && !!reqLabel;
|
||||
return (
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.96 }}
|
||||
@@ -235,7 +244,7 @@ function ItemCard({ item, owned, onOpen }: { item: ShopItem; owned: boolean; onO
|
||||
<Check className="size-3.5" strokeWidth={3} />
|
||||
</span>
|
||||
)}
|
||||
<div className="h-12 flex items-center justify-center">
|
||||
<div className={cn("h-12 flex items-center justify-center", locked && "opacity-40 grayscale")}>
|
||||
<Preview item={item} size={44} />
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-cream/90 truncate max-w-full">
|
||||
@@ -254,11 +263,16 @@ function ItemCard({ item, owned, onOpen }: { item: ShopItem; owned: boolean; onO
|
||||
<span
|
||||
className={cn(
|
||||
"w-full rounded-lg py-1.5 text-xs font-bold flex items-center justify-center gap-1",
|
||||
owned ? "bg-navy-900/60 text-teal-300" : "btn-gold"
|
||||
owned ? "bg-navy-900/60 text-teal-300" : locked ? "bg-navy-900/70 text-cream/55" : "btn-gold"
|
||||
)}
|
||||
>
|
||||
{owned ? (
|
||||
<Check className="size-3.5" />
|
||||
) : locked ? (
|
||||
<>
|
||||
<Lock className="size-3" />
|
||||
<span className="text-[10px] truncate">{reqLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Coins className="size-3.5" />
|
||||
@@ -274,18 +288,21 @@ function DetailSheet({
|
||||
item,
|
||||
owned,
|
||||
coins,
|
||||
reqLabel,
|
||||
onBuy,
|
||||
onClose,
|
||||
}: {
|
||||
item: ShopItem;
|
||||
owned: boolean;
|
||||
coins: number;
|
||||
reqLabel: string | null;
|
||||
onBuy: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t, locale } = useI18n();
|
||||
const name = locale === "fa" ? item.nameFa : item.nameEn;
|
||||
const desc = locale === "fa" ? item.descFa : item.descEn;
|
||||
const locked = !owned && !!reqLabel;
|
||||
const canAfford = coins >= item.price;
|
||||
|
||||
return (
|
||||
@@ -346,11 +363,13 @@ function DetailSheet({
|
||||
{/* buy */}
|
||||
<button
|
||||
onClick={onBuy}
|
||||
disabled={owned || !canAfford}
|
||||
disabled={owned || locked || !canAfford}
|
||||
className={cn(
|
||||
"press-3d w-full rounded-2xl py-3.5 mt-6 font-black flex items-center justify-center gap-2",
|
||||
owned
|
||||
? "bg-navy-900/60 text-teal-300"
|
||||
: locked
|
||||
? "bg-navy-900/60 text-cream/60"
|
||||
: canAfford
|
||||
? "btn-gold"
|
||||
: "bg-navy-900/60 text-rose-300"
|
||||
@@ -360,6 +379,10 @@ function DetailSheet({
|
||||
<>
|
||||
<Check className="size-4" /> {t("shop.owned")}
|
||||
</>
|
||||
) : locked ? (
|
||||
<>
|
||||
<Lock className="size-4" /> {reqLabel}
|
||||
</>
|
||||
) : canAfford ? (
|
||||
<>
|
||||
<Coins className="size-4" /> {t("shop.buy")} · {item.price.toLocaleString()}
|
||||
|
||||
@@ -310,6 +310,8 @@ const fa: Dict = {
|
||||
"shop.xp": "امتیاز تجربه (XP)",
|
||||
"shop.xpHint": "افزایش سریع سطح — XP گران است",
|
||||
"shop.includes": "شامل",
|
||||
"shop.reqLevel": "سطح",
|
||||
"shop.reqRating": "امتیاز",
|
||||
"reward.newTitle": "عنوان جدید",
|
||||
|
||||
"reactions.title": "شکلک",
|
||||
@@ -640,6 +642,8 @@ const en: Dict = {
|
||||
"shop.xp": "XP packs",
|
||||
"shop.xpHint": "Level up faster — XP is expensive",
|
||||
"shop.includes": "Includes",
|
||||
"shop.reqLevel": "Level",
|
||||
"shop.reqRating": "Rating",
|
||||
"reward.newTitle": "New title",
|
||||
|
||||
"reactions.title": "Emoji",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import {
|
||||
AVATARS,
|
||||
GIFT_TIERS,
|
||||
AchievementCategoryDef,
|
||||
AchievementCategoryId,
|
||||
AchievementDef,
|
||||
@@ -499,6 +500,62 @@ export const CARD_FRONTS: CardFrontDef[] = [
|
||||
{ id: "blackgold-face", nameFa: "طلای سیاه", nameEn: "Black Gold", bg1: "#2a2410", bg2: "#14110a", border: "#caa53a", price: 3200 },
|
||||
];
|
||||
|
||||
/* ------------------- Gated gift catalogue (titles + backs) -------------------
|
||||
* Purchasable gifts that are LOCKED until a level/rating gate is met (the tier is
|
||||
* encoded in the id `-t<n>-` so the server enforces it generically). Gift avatars
|
||||
* live in types.ts (same scheme). ~100 new gifts total across the three kinds. */
|
||||
function giftReq(tier: number) {
|
||||
const t = GIFT_TIERS[tier] ?? GIFT_TIERS[1];
|
||||
return { price: t.price, reqLevel: t.level || undefined, reqRating: t.rating || undefined };
|
||||
}
|
||||
|
||||
// 35 gift titles (fa, en), spread across the 5 tiers.
|
||||
const GIFT_TITLE_WORDS: [string, string][] = [
|
||||
["سردار", "Commander"], ["یل", "Brave"], ["پهلوان", "Hero"], ["شیردل", "Lionheart"], ["تیزهوش", "Sharp"], ["زبده", "Ace"], ["کارکشته", "Veteran"],
|
||||
["نخبه", "Elite"], ["بیرقیب", "Unrivaled"], ["شکستناپذیر", "Invincible"], ["آتشین", "Fiery"], ["طوفان", "Storm"], ["صاعقه", "Thunder"], ["کولاک", "Blizzard"],
|
||||
["تاجدار", "Crowned"], ["فرمانروا", "Ruler"], ["شاهباز", "Royal Falcon"], ["گرگ تنها", "Lone Wolf"], ["عقاب", "Eagle"], ["ققنوس", "Phoenix"], ["محاسب", "Tactician"],
|
||||
["نقشهکش", "Strategist"], ["بازیساز", "Playmaker"], ["پادشاه میز", "Table King"], ["حکمران", "Hokm Lord"], ["برگبرنده", "Trump Card"], ["دستمریزاد", "Masterstroke"], ["افسانهساز", "Legend-Maker"],
|
||||
["جواهر", "Gem"], ["الماس", "Diamond"], ["زرین", "Golden"], ["شاهنشاه", "Emperor"], ["اسطوره زنده", "Living Legend"], ["استاد اعظم", "Grandmaster"], ["تسخیرناپذیر", "Untamed"],
|
||||
];
|
||||
TITLES.push(
|
||||
...GIFT_TITLE_WORDS.map(([fa, en], i) => {
|
||||
const tier = Math.min(5, Math.floor(i / 7) + 1);
|
||||
const r = giftReq(tier);
|
||||
return { id: `t-g-t${tier}-${i + 1}`, nameFa: fa, nameEn: en, hintFa: "گیفت ویژه", hintEn: "Special gift", price: r.price, reqLevel: r.reqLevel, reqRating: r.reqRating };
|
||||
})
|
||||
);
|
||||
|
||||
// 20 gift card backs (palette × pattern), spread across the 5 tiers.
|
||||
const GIFT_BACK_PALETTE: { fa: string; en: string; c1: string; c2: string; accent: string; pattern: CardBackDef["pattern"]; motif?: string }[] = [
|
||||
{ fa: "نیلی", en: "Indigo", c1: "#2b2f7a", c2: "#10112e", accent: "#8aa0ff", pattern: "stripes" },
|
||||
{ fa: "زمردین", en: "Verdant", c1: "#0d5a44", c2: "#06231a", accent: "#4fe0a8", pattern: "dots" },
|
||||
{ fa: "غروب", en: "Sunset", c1: "#7a3b12", c2: "#2a1407", accent: "#ffb066", pattern: "rays" },
|
||||
{ fa: "بنفشه", en: "Violet", c1: "#532a7a", c2: "#1d0e2e", accent: "#c79cff", pattern: "argyle", motif: "✦" },
|
||||
{ fa: "فیروزه", en: "Turquoise", c1: "#0e5d6e", c2: "#06232a", accent: "#5fe0e0", pattern: "scales" },
|
||||
{ fa: "گلگون", en: "Rose", c1: "#7a2340", c2: "#2a0c16", accent: "#ff8ab0", pattern: "grid" },
|
||||
{ fa: "شنی", en: "Sand", c1: "#7a5a1a", c2: "#2a1e08", accent: "#e6c66a", pattern: "crosshatch" },
|
||||
{ fa: "یخی", en: "Frost", c1: "#3a5a7a", c2: "#13202e", accent: "#9fd0ff", pattern: "dots" },
|
||||
{ fa: "ارغوان", en: "Magenta", c1: "#7a1f5a", c2: "#2a0a1e", accent: "#ff8ae0", pattern: "rays" },
|
||||
{ fa: "جنگلی", en: "Forest", c1: "#2e5a1a", c2: "#0e2008", accent: "#9ee06a", pattern: "stripes" },
|
||||
{ fa: "نقرهای", en: "Silver", c1: "#4a4f5a", c2: "#16181d", accent: "#cfd6e6", pattern: "argyle", motif: "♢" },
|
||||
{ fa: "مسی", en: "Copper", c1: "#7a3f1a", c2: "#2a1508", accent: "#e6975a", pattern: "scales" },
|
||||
{ fa: "یاقوتی", en: "Garnet", c1: "#6a1326", c2: "#240710", accent: "#ff7a90", pattern: "filigree", motif: "♦" },
|
||||
{ fa: "کبریتی", en: "Sulfur", c1: "#6a6010", c2: "#242008", accent: "#ffe46a", pattern: "grid" },
|
||||
{ fa: "اطلسی", en: "Petunia", c1: "#3a2a7a", c2: "#12102e", accent: "#9a8aff", pattern: "royal", motif: "♛" },
|
||||
{ fa: "زمستانی", en: "Winter", c1: "#1f4a6a", c2: "#0a1a26", accent: "#7fc6ff", pattern: "filigree", motif: "❄" },
|
||||
{ fa: "آتشفشان", en: "Volcano", c1: "#6a1f10", c2: "#240a06", accent: "#ff7a4a", pattern: "rays", motif: "✦" },
|
||||
{ fa: "کهکشان", en: "Galaxy", c1: "#241a4a", c2: "#0a0820", accent: "#a98aff", pattern: "gem", motif: "✧" },
|
||||
{ fa: "زرافشان", en: "Goldspark", c1: "#5a4410", c2: "#241a06", accent: "#ffd76a", pattern: "royal", motif: "♔" },
|
||||
{ fa: "الماسی", en: "Brilliant", c1: "#103a4a", c2: "#06181f", accent: "#7fe6ff", pattern: "gem", motif: "♦" },
|
||||
];
|
||||
CARD_BACKS.push(
|
||||
...GIFT_BACK_PALETTE.map((b, i) => {
|
||||
const tier = Math.min(5, Math.floor(i / 4) + 1);
|
||||
const r = giftReq(tier);
|
||||
return { id: `cb-g-t${tier}-${i + 1}`, nameFa: b.fa, nameEn: b.en, c1: b.c1, c2: b.c2, accent: b.accent, pattern: b.pattern, motif: b.motif, price: r.price, reqLevel: r.reqLevel, reqRating: r.reqRating };
|
||||
})
|
||||
);
|
||||
|
||||
export function cardBackById(id: string): CardBackDef {
|
||||
return CARD_BACKS.find((c) => c.id === id) ?? CARD_BACKS[0];
|
||||
}
|
||||
|
||||
@@ -1031,6 +1031,8 @@ export class MockOnlineService implements OnlineService {
|
||||
preview: a.emoji,
|
||||
descFa: "آواتار نمایه شما در بازی و جدول",
|
||||
descEn: "Your profile avatar in games & leaderboard",
|
||||
reqLevel: a.reqLevel,
|
||||
reqRating: a.reqRating,
|
||||
}));
|
||||
const backItems: ShopItem[] = CARD_BACKS.filter((c) => c.price > 0).map((c) => ({
|
||||
id: c.id,
|
||||
@@ -1041,6 +1043,8 @@ export class MockOnlineService implements OnlineService {
|
||||
preview: c.accent,
|
||||
descFa: "طرح پشت کارتها روی میز",
|
||||
descEn: "The pattern on the back of your cards",
|
||||
reqLevel: c.reqLevel,
|
||||
reqRating: c.reqRating,
|
||||
}));
|
||||
const frontItems: ShopItem[] = CARD_FRONTS.filter((c) => c.price > 0).map((c) => ({
|
||||
id: c.id,
|
||||
@@ -1083,6 +1087,8 @@ export class MockOnlineService implements OnlineService {
|
||||
preview: "🏷️",
|
||||
descFa: "عنوان نمایه که زیر نام شما در بازی و لیستها نشان داده میشود",
|
||||
descEn: "A profile title shown under your name in games & lists",
|
||||
reqLevel: tt.reqLevel,
|
||||
reqRating: tt.reqRating,
|
||||
}));
|
||||
const xpItems: ShopItem[] = XP_PACKS.map((x) => ({
|
||||
id: x.id,
|
||||
@@ -1104,6 +1110,10 @@ export class MockOnlineService implements OnlineService {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (!item) return { ok: false, messageFa: "آیتم یافت نشد", messageEn: "Item not found" };
|
||||
|
||||
// Purchase gate: locked until the level/rating requirement is met.
|
||||
if ((item.reqLevel && p.level < item.reqLevel) || (item.reqRating && p.rating < item.reqRating))
|
||||
return { ok: false, messageFa: "هنوز باز نشده است", messageEn: "Locked — requirement not met" };
|
||||
|
||||
// XP packs are consumable — grant XP instead of adding to an owned list.
|
||||
if (item.kind === "xp") {
|
||||
if (p.coins < item.price)
|
||||
|
||||
@@ -224,6 +224,9 @@ export interface TitleDef {
|
||||
hintEn: string;
|
||||
/** >0 = purchasable in the shop (otherwise unlocked via stats/rank) */
|
||||
price?: number;
|
||||
/** purchase gate: must reach this level / rating before buying (locked until then) */
|
||||
reqLevel?: number;
|
||||
reqRating?: number;
|
||||
}
|
||||
|
||||
/** Distinct visual pattern families for card backs (see lib/cardBack.ts). */
|
||||
@@ -254,6 +257,9 @@ export interface CardBackDef {
|
||||
default?: boolean;
|
||||
unlockRating?: number;
|
||||
unlockWins?: number;
|
||||
/** purchase gate: locked until this level / rating is reached */
|
||||
reqLevel?: number;
|
||||
reqRating?: number;
|
||||
}
|
||||
|
||||
export interface CardFrontDef {
|
||||
@@ -465,6 +471,9 @@ export interface ShopItem {
|
||||
/** short fa/en description of what the item is/does */
|
||||
descFa?: string;
|
||||
descEn?: string;
|
||||
/** purchase gate: locked (shown, not buyable) until met. Server-enforced via the tier in the id. */
|
||||
reqLevel?: number;
|
||||
reqRating?: number;
|
||||
}
|
||||
|
||||
/* ------------------------------ Coin packs --------------------------- */
|
||||
@@ -606,6 +615,9 @@ export interface AvatarDef {
|
||||
default?: boolean; // owned from the start
|
||||
unlockRating?: number; // earned at this rating (better avatars = higher rank)
|
||||
unlockWins?: number;
|
||||
/** purchase gate: locked until this level / rating is reached */
|
||||
reqLevel?: number;
|
||||
reqRating?: number;
|
||||
}
|
||||
|
||||
export const AVATARS: AvatarDef[] = [
|
||||
@@ -638,6 +650,42 @@ export const AVATARS: AvatarDef[] = [
|
||||
{ id: "a-gem", emoji: "💠", unlockRating: 2100 },
|
||||
];
|
||||
|
||||
/* ----------------------- Gated gift catalogue ------------------------ */
|
||||
// Purchasable gifts that are LOCKED until a level/rating gate is met, then
|
||||
// buyable with coins. The tier is encoded in the id (`-t<n>-`) so the SERVER
|
||||
// can enforce the gate generically (see ProfileService.GiftTier). Higher tier =
|
||||
// more valuable = steeper gate + price. Shared by client UI + server.
|
||||
export const GIFT_TIERS: Record<number, { level: number; rating: number; price: number }> = {
|
||||
1: { level: 0, rating: 0, price: 800 },
|
||||
2: { level: 10, rating: 0, price: 1500 },
|
||||
3: { level: 20, rating: 0, price: 3000 },
|
||||
4: { level: 35, rating: 0, price: 6000 },
|
||||
5: { level: 0, rating: 1700, price: 12000 },
|
||||
};
|
||||
export function giftTierFromId(id: string): number {
|
||||
const m = /-t(\d)-/.exec(id);
|
||||
return m ? Number(m[1]) : 1;
|
||||
}
|
||||
function giftReq(tier: number) {
|
||||
const t = GIFT_TIERS[tier] ?? GIFT_TIERS[1];
|
||||
return { price: t.price, reqLevel: t.level || undefined, reqRating: t.rating || undefined };
|
||||
}
|
||||
|
||||
// 45 gift avatars (emoji), spread across the 5 tiers.
|
||||
const GIFT_AVATAR_EMOJIS = [
|
||||
"🐺", "🐯", "🐲", "🦅", "🦈", "🐉", "🦄", "🐙", "🦂", // t1-ish
|
||||
"🐧", "🐨", "🐸", "🦔", "🐢", "🦩", "🦚", "🐬", "🦓", // t2
|
||||
"🦁", "🐅", "🐆", "🦛", "🦏", "🐘", "🦒", "🦌", "🐊", // t3
|
||||
"👻", "🤡", "👽", "🤠", "🦸", "🦹", "🧛", "🧟", "🧜", // t4
|
||||
"🔱", "⚜️", "🛡️", "⚔️", "🏵️", "🎴", "🀄", "🪅", "🧿", // t5
|
||||
];
|
||||
const GIFT_AVATARS: AvatarDef[] = GIFT_AVATAR_EMOJIS.map((emoji, i) => {
|
||||
const tier = Math.min(5, Math.floor(i / 9) + 1);
|
||||
const r = giftReq(tier);
|
||||
return { id: `a-g-t${tier}-${i + 1}`, emoji, price: r.price, reqLevel: r.reqLevel, reqRating: r.reqRating };
|
||||
});
|
||||
AVATARS.push(...GIFT_AVATARS);
|
||||
|
||||
export function avatarEmoji(id: string): string {
|
||||
return AVATARS.find((a) => a.id === id)?.emoji ?? "🦊";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user