Build Hokm card game: offline vs-AI + online social/gamification (mock backend)
- Pure-TS Hokm engine (deal, hakem, trump, tricks, scoring, Kot) + AI bots - Persian-luxury RTL UI (Next 16 / React 19 / Tailwind v4 / Framer Motion / Zustand) - Online platform behind OnlineService seam (mock now, .NET SignalR later): auth (phone OTP + email/Google), profiles, friends, private rooms with partner pick, ranked matchmaking, leaderboard, shop - Gamification: ranks/leagues, coins, XP/levels, daily rewards, achievements - i18n fa/en, PWA manifest, engine + gamification sims Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,572 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Crown, LogOut } from "lucide-react";
|
||||
import { useGameStore } from "@/lib/game-store";
|
||||
import { legalMoves } from "@/lib/hokm/engine";
|
||||
import { sortHand } from "@/lib/hokm/deck";
|
||||
import {
|
||||
Card,
|
||||
Seat,
|
||||
Suit,
|
||||
SUITS,
|
||||
SUIT_IS_RED,
|
||||
SUIT_SYMBOL,
|
||||
teamOf,
|
||||
} from "@/lib/hokm/types";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { PlayingCard } from "./PlayingCard";
|
||||
|
||||
export function GameTable({ onExit }: { onExit?: () => void } = {}) {
|
||||
const game = useGameStore((s) => s.game);
|
||||
const reset = useGameStore((s) => s.reset);
|
||||
const mode = useGameStore((s) => s.mode);
|
||||
const { t } = useI18n();
|
||||
|
||||
const exit = onExit ?? reset;
|
||||
const { phase, players, hakem, trump, turn, currentTrick } = game;
|
||||
|
||||
const legalIds = new Set(
|
||||
phase === "playing" && turn === 0
|
||||
? legalMoves(game, 0).map((c) => c.id)
|
||||
: []
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="persian-pattern relative h-dvh w-full overflow-hidden">
|
||||
{/* Top HUD */}
|
||||
<div className="absolute top-0 inset-x-0 z-30 flex items-start justify-between p-3 sm:p-4">
|
||||
<Scoreboard />
|
||||
<div className="flex items-center gap-2">
|
||||
{trump && <TrumpBadge trump={trump} />}
|
||||
<button
|
||||
onClick={exit}
|
||||
className="glass rounded-full p-2.5 hover:bg-navy-800 transition"
|
||||
title={t("hud.quit")}
|
||||
>
|
||||
<LogOut className="size-4 text-cream/80" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Felt table */}
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div className="felt relative w-[min(94vw,1100px)] h-[min(82vh,720px)] rounded-[42%]">
|
||||
{/* opponent + partner seats */}
|
||||
<SeatAvatar seat={2} className="absolute top-3 left-1/2 -translate-x-1/2" />
|
||||
<SeatAvatar seat={1} className="absolute top-1/2 right-3 -translate-y-1/2" />
|
||||
<SeatAvatar seat={3} className="absolute top-1/2 left-3 -translate-y-1/2" />
|
||||
|
||||
{/* opponents' face-down hands */}
|
||||
<OpponentHand seat={2} className="absolute top-20 left-1/2 -translate-x-1/2" horizontal />
|
||||
<OpponentHand seat={1} className="absolute top-1/2 right-16 -translate-y-1/2" />
|
||||
<OpponentHand seat={3} className="absolute top-1/2 left-16 -translate-y-1/2" />
|
||||
|
||||
{/* center trick area */}
|
||||
<TrickArea trick={currentTrick} winner={game.lastTrickWinner} phase={phase} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Your hand */}
|
||||
<PlayerHand legalIds={legalIds} />
|
||||
|
||||
{/* Turn indicator */}
|
||||
<TurnIndicator />
|
||||
|
||||
{/* Overlays */}
|
||||
<AnimatePresence>
|
||||
{phase === "selecting-hakem" && <HakemOverlay key="hakem" />}
|
||||
{phase === "choosing-trump" && players[hakem!]?.isHuman && (
|
||||
<TrumpChooser key="trump" />
|
||||
)}
|
||||
{phase === "round-over" && <RoundOverlay key="round" />}
|
||||
{phase === "match-over" && mode === "ai" && (
|
||||
<MatchOverlay key="match" onExit={exit} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------- Scoreboard ----------------------------- */
|
||||
|
||||
function Scoreboard() {
|
||||
const game = useGameStore((s) => s.game);
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="glass rounded-2xl px-4 py-2.5 flex items-center gap-4">
|
||||
<ScoreCol
|
||||
label={t("team.us")}
|
||||
tricks={game.roundTricks[0]}
|
||||
score={game.matchScore[0]}
|
||||
accent="text-teal-400"
|
||||
/>
|
||||
<div className="text-cream/30 text-lg font-light">/</div>
|
||||
<ScoreCol
|
||||
label={t("team.them")}
|
||||
tricks={game.roundTricks[1]}
|
||||
score={game.matchScore[1]}
|
||||
accent="text-rose-400"
|
||||
/>
|
||||
<div className="ltr:ml-2 rtl:mr-2 ltr:border-l rtl:border-r border-gold-500/20 ltr:pl-3 rtl:pr-3">
|
||||
<div className="text-[10px] text-cream/50">{t("home.target")}</div>
|
||||
<div className="gold-text font-bold text-center">{game.targetScore}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreCol({
|
||||
label,
|
||||
tricks,
|
||||
score,
|
||||
accent,
|
||||
}: {
|
||||
label: string;
|
||||
tricks: number;
|
||||
score: number;
|
||||
accent: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="text-center min-w-14">
|
||||
<div className={cn("text-xs font-semibold", accent)}>{label}</div>
|
||||
<div className="text-2xl font-black leading-none">{score}</div>
|
||||
<div className="text-[10px] text-cream/45 mt-0.5">
|
||||
{t("score.tricks")}: {tricks}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------- Trump badge ---------------------------- */
|
||||
|
||||
function TrumpBadge({ trump }: { trump: Suit }) {
|
||||
const { t } = useI18n();
|
||||
const red = SUIT_IS_RED[trump];
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -20 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
className="glass rounded-2xl px-3 py-2 flex items-center gap-2"
|
||||
>
|
||||
<span className="text-[10px] text-gold-400 font-semibold">
|
||||
{t("trump.label")}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-2xl leading-none",
|
||||
red ? "text-rose-400" : "text-cream"
|
||||
)}
|
||||
>
|
||||
{SUIT_SYMBOL[trump]}
|
||||
</span>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------- Seat avatar ---------------------------- */
|
||||
|
||||
function SeatAvatar({ seat, className }: { seat: Seat; className?: string }) {
|
||||
const game = useGameStore((s) => s.game);
|
||||
const sp = useGameStore((s) => s.seatPlayers[seat]);
|
||||
const player = game.players[seat];
|
||||
const active =
|
||||
(game.phase === "playing" && game.turn === seat) ||
|
||||
(game.phase === "choosing-trump" && game.hakem === seat);
|
||||
const isHakem = game.hakem === seat;
|
||||
const team = teamOf(seat);
|
||||
const name = sp?.name ?? player.name;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center gap-1", className)}>
|
||||
<motion.div
|
||||
animate={
|
||||
active
|
||||
? { boxShadow: "0 0 0 3px rgba(212,175,55,0.9), 0 0 24px rgba(212,175,55,0.5)" }
|
||||
: { boxShadow: "0 0 0 1px rgba(212,175,55,0.2)" }
|
||||
}
|
||||
className={cn(
|
||||
"relative size-12 rounded-full flex items-center justify-center font-bold text-xl",
|
||||
team === 0 ? "bg-teal-700/80 text-teal-100" : "bg-rose-900/70 text-rose-100"
|
||||
)}
|
||||
>
|
||||
{sp?.avatar ?? name.charAt(0)}
|
||||
{isHakem && (
|
||||
<Crown className="absolute -top-3 size-4 text-gold-400 fill-gold-500" />
|
||||
)}
|
||||
</motion.div>
|
||||
<span className="text-[11px] text-cream/80 max-w-20 truncate">{name}</span>
|
||||
{sp && sp.level > 0 && (
|
||||
<span className="text-[9px] text-gold-400/80 leading-none">
|
||||
{team === 0 ? "" : ""}
|
||||
{`Lv ${sp.level}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------- Opponent hands --------------------------- */
|
||||
|
||||
function OpponentHand({
|
||||
seat,
|
||||
className,
|
||||
horizontal,
|
||||
}: {
|
||||
seat: Seat;
|
||||
className?: string;
|
||||
horizontal?: boolean;
|
||||
}) {
|
||||
const count = useGameStore((s) => s.game.players[seat].hand.length);
|
||||
const cards = Array.from({ length: count });
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
horizontal ? "flex-row" : "flex-col",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{cards.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={horizontal ? { marginInlineStart: i === 0 ? 0 : -34 } : { marginTop: i === 0 ? 0 : -48 }}
|
||||
>
|
||||
<PlayingCard faceDown size="sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------- Trick area ----------------------------- */
|
||||
|
||||
const TRICK_OFFSET: Record<Seat, { x: number; y: number }> = {
|
||||
0: { x: 0, y: 70 },
|
||||
1: { x: 96, y: 0 },
|
||||
2: { x: 0, y: -70 },
|
||||
3: { x: -96, y: 0 },
|
||||
};
|
||||
const TRICK_ENTER: Record<Seat, { x: number; y: number }> = {
|
||||
0: { x: 0, y: 260 },
|
||||
1: { x: 360, y: 0 },
|
||||
2: { x: 0, y: -260 },
|
||||
3: { x: -360, y: 0 },
|
||||
};
|
||||
|
||||
function TrickArea({
|
||||
trick,
|
||||
winner,
|
||||
phase,
|
||||
}: {
|
||||
trick: { seat: Seat; card: Card }[];
|
||||
winner: Seat | null;
|
||||
phase: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="relative size-1 ">
|
||||
<AnimatePresence>
|
||||
{trick.map((pc) => {
|
||||
const off = TRICK_OFFSET[pc.seat];
|
||||
const enter = TRICK_ENTER[pc.seat];
|
||||
const isWinner =
|
||||
phase === "trick-complete" && winner === pc.seat;
|
||||
return (
|
||||
<motion.div
|
||||
key={pc.card.id}
|
||||
initial={{ x: enter.x, y: enter.y, opacity: 0, scale: 0.7 }}
|
||||
animate={{
|
||||
x: off.x,
|
||||
y: off.y,
|
||||
opacity: 1,
|
||||
scale: isWinner ? 1.12 : 1,
|
||||
}}
|
||||
exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.25 } }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 26 }}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2"
|
||||
style={{
|
||||
filter: isWinner
|
||||
? "drop-shadow(0 0 14px rgba(212,175,55,0.9))"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<PlayingCard card={pc.card} size="md" />
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------- Player hand ---------------------------- */
|
||||
|
||||
function PlayerHand({ legalIds }: { legalIds: Set<string> }) {
|
||||
const hand = useGameStore((s) => s.game.players[0].hand);
|
||||
const phase = useGameStore((s) => s.game.phase);
|
||||
const turn = useGameStore((s) => s.game.turn);
|
||||
const playHuman = useGameStore((s) => s.playHuman);
|
||||
|
||||
const sorted = sortHand(hand);
|
||||
const myTurn = phase === "playing" && turn === 0;
|
||||
const n = sorted.length;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-0 inset-x-0 z-20 flex justify-center pb-3 pointer-events-none">
|
||||
<div className="relative flex items-end justify-center pointer-events-auto">
|
||||
{sorted.map((card, i) => {
|
||||
const playable = myTurn && legalIds.has(card.id);
|
||||
const dimmed = myTurn && !playable;
|
||||
const mid = (n - 1) / 2;
|
||||
const rot = (i - mid) * 3.2;
|
||||
const lift = Math.abs(i - mid) * 4;
|
||||
return (
|
||||
<motion.button
|
||||
key={card.id}
|
||||
layout
|
||||
initial={{ y: 120, opacity: 0 }}
|
||||
animate={{ y: lift, opacity: 1, rotate: rot }}
|
||||
transition={{ type: "spring", stiffness: 280, damping: 28, delay: i * 0.015 }}
|
||||
whileHover={playable ? { y: lift - 26, scale: 1.06, zIndex: 50 } : {}}
|
||||
onClick={() => playable && playHuman(card)}
|
||||
disabled={!playable}
|
||||
data-card={card.id}
|
||||
data-playable={playable ? "1" : "0"}
|
||||
style={{ marginInlineStart: i === 0 ? 0 : -22 }}
|
||||
className={cn(
|
||||
"origin-bottom",
|
||||
playable && "cursor-pointer",
|
||||
!myTurn && "cursor-default"
|
||||
)}
|
||||
>
|
||||
<PlayingCard
|
||||
card={card}
|
||||
size="lg"
|
||||
dimmed={dimmed}
|
||||
className={cn(playable && "ring-2 ring-gold-400/70")}
|
||||
/>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* --------------------------- Turn indicator --------------------------- */
|
||||
|
||||
function TurnIndicator() {
|
||||
const game = useGameStore((s) => s.game);
|
||||
const { t } = useI18n();
|
||||
if (game.phase !== "playing" || game.turn == null) return null;
|
||||
const isYou = game.turn === 0;
|
||||
const name = game.players[game.turn].name;
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={game.turn}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute bottom-[150px] left-1/2 -translate-x-1/2 z-30"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-sm font-semibold glass",
|
||||
isYou ? "text-gold-300" : "text-cream/70"
|
||||
)}
|
||||
>
|
||||
{isYou ? t("turn.you") : t("turn.other", { name })}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------ Overlays ------------------------------ */
|
||||
|
||||
function Backdrop({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 z-40 flex items-center justify-center bg-navy-950/70 backdrop-blur-sm p-5"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function HakemOverlay() {
|
||||
const game = useGameStore((s) => s.game);
|
||||
const { t } = useI18n();
|
||||
const hakemName = game.hakem != null ? game.players[game.hakem].name : "";
|
||||
return (
|
||||
<Backdrop>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 10 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
className="glass rounded-3xl p-7 text-center max-w-sm w-full"
|
||||
>
|
||||
<h2 className="gold-text text-2xl font-black">{t("hakem.title")}</h2>
|
||||
<p className="text-cream/60 text-sm mt-1">{t("hakem.desc")}</p>
|
||||
<div className="flex flex-wrap justify-center gap-1.5 mt-5">
|
||||
{game.hakemDraw.map((pc, i) => (
|
||||
<motion.div
|
||||
key={pc.card.id}
|
||||
initial={{ opacity: 0, y: -20, rotateY: 90 }}
|
||||
animate={{ opacity: 1, y: 0, rotateY: 0 }}
|
||||
transition={{ delay: i * 0.12 }}
|
||||
>
|
||||
<PlayingCard card={pc.card} size="sm" />
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: game.hakemDraw.length * 0.12 + 0.2 }}
|
||||
className="mt-5 text-gold-300 font-bold text-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
<Crown className="size-5 text-gold-400 fill-gold-500" />
|
||||
{t("hakem.is", { name: hakemName })}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
|
||||
function TrumpChooser() {
|
||||
const choose = useGameStore((s) => s.chooseTrump);
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Backdrop>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 10 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
className="glass rounded-3xl p-7 text-center max-w-sm w-full"
|
||||
>
|
||||
<h2 className="gold-text text-2xl font-black">{t("trump.title")}</h2>
|
||||
<p className="text-cream/60 text-sm mt-1">{t("trump.desc")}</p>
|
||||
<div className="grid grid-cols-2 gap-3 mt-6">
|
||||
{SUITS.map((suit) => {
|
||||
const red = SUIT_IS_RED[suit];
|
||||
return (
|
||||
<motion.button
|
||||
key={suit}
|
||||
whileHover={{ scale: 1.05, y: -2 }}
|
||||
whileTap={{ scale: 0.96 }}
|
||||
onClick={() => choose(suit)}
|
||||
className="rounded-2xl bg-navy-900/80 gold-border py-6 flex items-center justify-center hover:bg-navy-800 transition"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-5xl",
|
||||
red ? "text-rose-400" : "text-cream"
|
||||
)}
|
||||
>
|
||||
{SUIT_SYMBOL[suit]}
|
||||
</span>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
|
||||
function RoundOverlay() {
|
||||
const game = useGameStore((s) => s.game);
|
||||
const { t } = useI18n();
|
||||
const r = game.lastRoundResult;
|
||||
if (!r) return null;
|
||||
const weWon = r.winningTeam === 0;
|
||||
return (
|
||||
<Backdrop>
|
||||
<motion.div
|
||||
initial={{ scale: 0.85, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
className="glass rounded-3xl p-8 text-center max-w-sm w-full"
|
||||
>
|
||||
<h2 className="gold-text text-3xl font-black">{t("round.over")}</h2>
|
||||
{r.kot && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200, delay: 0.15 }}
|
||||
className="mt-3 inline-block rounded-full btn-gold px-5 py-1.5 text-lg font-black"
|
||||
>
|
||||
{t("round.kot")}🔥
|
||||
</motion.div>
|
||||
)}
|
||||
<p
|
||||
className={cn(
|
||||
"mt-4 text-xl font-bold",
|
||||
weWon ? "text-teal-300" : "text-rose-300"
|
||||
)}
|
||||
>
|
||||
{t("round.won", { team: weWon ? t("team.0") : t("team.1") })}
|
||||
</p>
|
||||
<p className="text-cream/70 mt-2">
|
||||
{t("round.score", {
|
||||
us: game.matchScore[0],
|
||||
them: game.matchScore[1],
|
||||
})}
|
||||
</p>
|
||||
<p className="text-cream/40 text-sm mt-5 animate-pulse">
|
||||
{t("round.next")}
|
||||
</p>
|
||||
</motion.div>
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchOverlay({ onExit }: { onExit: () => void }) {
|
||||
const game = useGameStore((s) => s.game);
|
||||
const { t } = useI18n();
|
||||
const youWin = game.matchWinner === 0;
|
||||
return (
|
||||
<Backdrop>
|
||||
<motion.div
|
||||
initial={{ scale: 0.85 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="glass rounded-3xl p-9 text-center max-w-sm w-full"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ rotate: -15, scale: 0 }}
|
||||
animate={{ rotate: 0, scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 160 }}
|
||||
className="text-6xl mb-3"
|
||||
>
|
||||
{youWin ? "🏆" : "🎴"}
|
||||
</motion.div>
|
||||
<h2 className="gold-text text-3xl font-black">{t("match.over")}</h2>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-3 text-2xl font-bold",
|
||||
youWin ? "text-gold-300" : "text-rose-300"
|
||||
)}
|
||||
>
|
||||
{youWin ? t("match.youWin") : t("match.youLose")}
|
||||
</p>
|
||||
<p className="text-cream/70 mt-2">
|
||||
{t("round.score", {
|
||||
us: game.matchScore[0],
|
||||
them: game.matchScore[1],
|
||||
})}
|
||||
</p>
|
||||
<div className="mt-7 flex gap-3">
|
||||
<button onClick={onExit} className="btn-gold flex-1 rounded-xl py-3">
|
||||
{t("match.menu")}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user