2026-06-04 10:11:00 +03:30
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
|
|
|
import { Bot, Copy, UserPlus, X } from "lucide-react";
|
|
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
|
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
|
|
|
|
|
import { useGameStore } from "@/lib/game-store";
|
|
|
|
|
import { useOnlineStore } from "@/lib/online-store";
|
|
|
|
|
import { useUIStore } from "@/lib/ui-store";
|
|
|
|
|
import { useI18n } from "@/lib/i18n";
|
|
|
|
|
import { Friend, RoomSeat, avatarEmoji } from "@/lib/online/types";
|
|
|
|
|
import { cn } from "@/lib/cn";
|
|
|
|
|
|
|
|
|
|
export function RoomScreen() {
|
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
const room = useOnlineStore((s) => s.room);
|
|
|
|
|
const friends = useOnlineStore((s) => s.friends);
|
|
|
|
|
const loadFriends = useOnlineStore((s) => s.loadFriends);
|
|
|
|
|
const setPartner = useOnlineStore((s) => s.setPartner);
|
|
|
|
|
const inviteToSeat = useOnlineStore((s) => s.inviteToSeat);
|
|
|
|
|
const addBot = useOnlineStore((s) => s.addBot);
|
|
|
|
|
const clearSeat = useOnlineStore((s) => s.clearSeat);
|
|
|
|
|
const startRoom = useOnlineStore((s) => s.startRoom);
|
|
|
|
|
const leaveRoom = useOnlineStore((s) => s.leaveRoom);
|
|
|
|
|
const newOnlineMatch = useGameStore((s) => s.newOnlineMatch);
|
|
|
|
|
const goGame = useUIStore((s) => s.goGame);
|
|
|
|
|
const go = useUIStore((s) => s.go);
|
|
|
|
|
|
|
|
|
|
const [picker, setPicker] = useState<null | { seat: 1 | 2 | 3 }>(null);
|
|
|
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadFriends();
|
|
|
|
|
}, [loadFriends]);
|
|
|
|
|
|
|
|
|
|
if (!room) return null;
|
|
|
|
|
const seat = (n: number) => room.seats.find((s) => s.seat === n)!;
|
|
|
|
|
|
|
|
|
|
const pick = async (friend: Friend) => {
|
|
|
|
|
if (!picker) return;
|
|
|
|
|
if (picker.seat === 2) await setPartner(friend.id);
|
|
|
|
|
else await inviteToSeat(picker.seat, friend.id);
|
|
|
|
|
setPicker(null);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const copyCode = async () => {
|
|
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(room.code);
|
|
|
|
|
setCopied(true);
|
|
|
|
|
setTimeout(() => setCopied(false), 1500);
|
|
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const start = async () => {
|
|
|
|
|
await startRoom();
|
|
|
|
|
const r = useOnlineStore.getState().room!;
|
|
|
|
|
const players = r.seats
|
|
|
|
|
.slice()
|
|
|
|
|
.sort((a, b) => a.seat - b.seat)
|
|
|
|
|
.map((s) => ({
|
|
|
|
|
displayName: s.player!.displayName,
|
|
|
|
|
avatar: s.player!.avatar,
|
|
|
|
|
level: s.player!.level,
|
|
|
|
|
}));
|
|
|
|
|
newOnlineMatch({ players, targetScore: r.targetScore, stake: r.stake, ranked: r.ranked });
|
|
|
|
|
goGame("home");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const leave = async () => {
|
|
|
|
|
await leaveRoom();
|
|
|
|
|
go("online");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2026-06-11 01:56:52 +03:30
|
|
|
<ScreenShell hideNav>
|
2026-06-04 10:11:00 +03:30
|
|
|
<ScreenHeader
|
|
|
|
|
title={t("room.title")}
|
|
|
|
|
back="online"
|
|
|
|
|
right={
|
|
|
|
|
<button
|
|
|
|
|
onClick={copyCode}
|
|
|
|
|
className="glass rounded-full px-3 py-1.5 text-xs flex items-center gap-1.5 hover:bg-navy-800/80"
|
|
|
|
|
>
|
|
|
|
|
<Copy className="size-3.5 text-gold-400" />
|
|
|
|
|
<span className="tabular-nums tracking-wider">{copied ? t("common.copied") : room.code}</span>
|
|
|
|
|
</button>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-06-11 13:21:28 +03:30
|
|
|
{/* teams: stacked on phones, side-by-side in landscape so all 4 seats fit */}
|
|
|
|
|
<div className="grid gap-x-4 landscape:grid-cols-2">
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-xs text-teal-300 font-bold mb-2">{t("team.us")}</h3>
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
<SeatCard seat={seat(0)} role="you" onInvite={() => {}} onBot={() => {}} onClear={() => {}} />
|
|
|
|
|
<SeatCard
|
|
|
|
|
seat={seat(2)}
|
|
|
|
|
role="partner"
|
|
|
|
|
onInvite={() => setPicker({ seat: 2 })}
|
|
|
|
|
onBot={() => addBot(2)}
|
|
|
|
|
onClear={() => clearSeat(2)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-06-04 10:11:00 +03:30
|
|
|
|
2026-06-11 13:21:28 +03:30
|
|
|
<div className="mt-5 landscape:mt-0">
|
|
|
|
|
<h3 className="text-xs text-rose-300 font-bold mb-2">{t("room.opponents")}</h3>
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
<SeatCard
|
|
|
|
|
seat={seat(1)}
|
|
|
|
|
role="opp"
|
|
|
|
|
onInvite={() => setPicker({ seat: 1 })}
|
|
|
|
|
onBot={() => addBot(1)}
|
|
|
|
|
onClear={() => clearSeat(1)}
|
|
|
|
|
/>
|
|
|
|
|
<SeatCard
|
|
|
|
|
seat={seat(3)}
|
|
|
|
|
role="opp"
|
|
|
|
|
onInvite={() => setPicker({ seat: 3 })}
|
|
|
|
|
onBot={() => addBot(3)}
|
|
|
|
|
onClear={() => clearSeat(3)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-06-04 10:11:00 +03:30
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-3 mt-7">
|
|
|
|
|
<button onClick={leave} className="glass rounded-xl px-5 py-3 text-cream/70 hover:text-cream">
|
|
|
|
|
{t("room.leave")}
|
|
|
|
|
</button>
|
|
|
|
|
<button onClick={start} className="btn-gold flex-1 rounded-xl py-3 text-lg">
|
|
|
|
|
{t("room.start")}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* friend picker */}
|
|
|
|
|
<AnimatePresence>
|
|
|
|
|
{picker && (
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ opacity: 0 }}
|
|
|
|
|
animate={{ opacity: 1 }}
|
|
|
|
|
exit={{ opacity: 0 }}
|
|
|
|
|
onClick={() => setPicker(null)}
|
|
|
|
|
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-navy-950/80 backdrop-blur-sm p-4"
|
|
|
|
|
>
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ y: 40, opacity: 0 }}
|
|
|
|
|
animate={{ y: 0, opacity: 1 }}
|
|
|
|
|
exit={{ y: 40, opacity: 0 }}
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
2026-06-11 10:42:49 +03:30
|
|
|
className="panel rounded-3xl p-5 w-full max-w-sm max-h-[70vh] overflow-y-auto"
|
2026-06-04 10:11:00 +03:30
|
|
|
>
|
|
|
|
|
<h3 className="text-lg font-black gold-text mb-3">{t("room.pickFriend")}</h3>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{friends.map((f) => (
|
|
|
|
|
<button
|
|
|
|
|
key={f.id}
|
|
|
|
|
onClick={() => pick(f)}
|
|
|
|
|
className="w-full glass rounded-xl p-2.5 flex items-center gap-3 hover:bg-navy-800/80 transition text-start"
|
|
|
|
|
>
|
|
|
|
|
<span className="text-2xl">{avatarEmoji(f.avatar)}</span>
|
|
|
|
|
<span className="flex-1 text-sm font-semibold text-cream">{f.displayName}</span>
|
|
|
|
|
<span className="text-[11px] text-cream/45">
|
|
|
|
|
{t("common.level")} {f.level}
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
)}
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
</ScreenShell>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SeatCard({
|
|
|
|
|
seat,
|
|
|
|
|
role,
|
|
|
|
|
onInvite,
|
|
|
|
|
onBot,
|
|
|
|
|
onClear,
|
|
|
|
|
}: {
|
|
|
|
|
seat: RoomSeat;
|
|
|
|
|
role: "you" | "partner" | "opp";
|
|
|
|
|
onInvite: () => void;
|
|
|
|
|
onBot: () => void;
|
|
|
|
|
onClear: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const { t } = useI18n();
|
|
|
|
|
const filled = seat.kind !== "empty";
|
|
|
|
|
const label =
|
|
|
|
|
role === "you" ? t("seat.you") : role === "partner" ? t("room.partner") : t("room.opponents");
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"rounded-2xl p-4 min-h-32 flex flex-col items-center justify-center gap-2 border",
|
|
|
|
|
role === "opp" ? "border-rose-500/25 bg-rose-950/20" : "border-teal-500/25 bg-teal-950/20"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span className="text-[10px] text-cream/50">{label}</span>
|
|
|
|
|
{filled ? (
|
|
|
|
|
<>
|
|
|
|
|
<span className="text-3xl">{avatarEmoji(seat.player?.avatar ?? "a-fox")}</span>
|
|
|
|
|
<span className="text-sm font-bold text-cream text-center max-w-full truncate">
|
|
|
|
|
{seat.player?.displayName}
|
|
|
|
|
{seat.kind === "bot" && <span className="text-cream/40"> 🤖</span>}
|
|
|
|
|
</span>
|
|
|
|
|
{seat.kind === "invited" ? (
|
|
|
|
|
<span className="text-[10px] text-gold-300 animate-pulse">{t("room.waiting")}</span>
|
|
|
|
|
) : (
|
|
|
|
|
role !== "you" && (
|
2026-06-06 18:39:24 +03:30
|
|
|
<button
|
|
|
|
|
onClick={onClear}
|
|
|
|
|
aria-label={t("friends.remove")}
|
|
|
|
|
className="grid place-items-center min-h-9 min-w-9 rounded-full text-rose-300/70 hover:text-rose-300 hover:bg-rose-500/10 transition"
|
|
|
|
|
>
|
|
|
|
|
<X className="size-4" />
|
2026-06-04 10:11:00 +03:30
|
|
|
</button>
|
|
|
|
|
)
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
2026-06-06 18:39:24 +03:30
|
|
|
<div className="flex flex-col gap-2 w-full">
|
2026-06-04 10:11:00 +03:30
|
|
|
<button
|
|
|
|
|
onClick={onInvite}
|
2026-06-06 18:39:24 +03:30
|
|
|
className="rounded-xl bg-navy-900/70 gold-border py-2.5 text-xs text-cream/80 hover:bg-navy-800 active:scale-[0.98] transition flex items-center justify-center gap-1.5"
|
2026-06-04 10:11:00 +03:30
|
|
|
>
|
2026-06-06 18:39:24 +03:30
|
|
|
<UserPlus className="size-4" />
|
2026-06-04 10:11:00 +03:30
|
|
|
{t("room.invite")}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={onBot}
|
2026-06-06 18:39:24 +03:30
|
|
|
className="rounded-xl bg-navy-900/70 gold-border py-2.5 text-xs text-cream/80 hover:bg-navy-800 active:scale-[0.98] transition flex items-center justify-center gap-1.5"
|
2026-06-04 10:11:00 +03:30
|
|
|
>
|
2026-06-06 18:39:24 +03:30
|
|
|
<Bot className="size-4" />
|
2026-06-04 10:11:00 +03:30
|
|
|
{t("room.addBot")}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|