UX: landscape result screen, chat emojis, unread badges, remove XP text
- PostMatchRewardsModal: short-height (landscape) compaction so the win/forfeit result fits without overflow (smaller emoji/coins/padding, max-h 94dvh, wider). - Chat: emoji/sticker picker (owned reactions) — tap to send; hidden on focus. - Unread messages: online-store now tracks a total `unread` (from listConversations); NavRail Friends icon shows a badge (unread + requests), refreshed every 12s on every screen; Friends «پیامها» tab badged too. (Per-conversation unread badges already existed.) - Remove "XP گران است" / "XP is expensive" from shop.xpHint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Home, ShoppingBag, Star, Trophy, User, Users } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useUIStore, type Screen } from "@/lib/ui-store";
|
||||
import { useSessionStore } from "@/lib/session-store";
|
||||
import { useOnlineStore } from "@/lib/online-store";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type Item = { key: Screen; icon: typeof Home; label: string; authed?: boolean };
|
||||
type Item = { key: Screen; icon: typeof Home; label: string; authed?: boolean; badge?: number };
|
||||
|
||||
/**
|
||||
* UNO-style primary navigation. A vertical rail pinned to the side in landscape
|
||||
@@ -17,13 +19,26 @@ export function NavRail({ bottom = false }: { bottom?: boolean }) {
|
||||
const screen = useUIStore((s) => s.screen);
|
||||
const go = useUIStore((s) => s.go);
|
||||
const isAuthed = useSessionStore((s) => s.isAuthed);
|
||||
const unread = useOnlineStore((s) => s.unread);
|
||||
const requests = useOnlineStore((s) => s.requests);
|
||||
const refreshUnread = useOnlineStore((s) => s.refreshUnread);
|
||||
const { t } = useI18n();
|
||||
|
||||
// Keep the Friends badge live (unread chats + friend requests) on every screen.
|
||||
useEffect(() => {
|
||||
if (!isAuthed) return;
|
||||
refreshUnread();
|
||||
const id = setInterval(refreshUnread, 12000);
|
||||
return () => clearInterval(id);
|
||||
}, [isAuthed, refreshUnread]);
|
||||
|
||||
const friendsBadge = unread + requests.length;
|
||||
|
||||
const all: Item[] = [
|
||||
{ key: "home", icon: Home, label: t("nav.home") },
|
||||
{ key: "profile", icon: User, label: t("menu.profile") },
|
||||
{ key: "shop", icon: ShoppingBag, label: t("menu.shop") },
|
||||
{ key: "friends", icon: Users, label: t("menu.friends"), authed: true },
|
||||
{ key: "friends", icon: Users, label: t("menu.friends"), authed: true, badge: friendsBadge },
|
||||
{ key: "leaderboard", icon: Trophy, label: t("menu.leaderboard") },
|
||||
{ key: "achievements", icon: Star, label: t("achv.title") },
|
||||
];
|
||||
@@ -52,11 +67,16 @@ export function NavRail({ bottom = false }: { bottom?: boolean }) {
|
||||
key={it.key}
|
||||
onClick={() => go(it.authed && !isAuthed ? "auth" : it.key)}
|
||||
className={cn(
|
||||
"flex min-w-[54px] flex-col items-center justify-center gap-1 rounded-2xl px-1.5 py-2 transition",
|
||||
"relative flex min-w-[54px] flex-col items-center justify-center gap-1 rounded-2xl px-1.5 py-2 transition",
|
||||
!bottom && "landscape:w-full landscape:py-2.5",
|
||||
active ? "btn-gold shadow-lg" : "text-cream/55 hover:bg-navy-800/60 hover:text-cream"
|
||||
)}
|
||||
>
|
||||
{!!it.badge && it.badge > 0 && (
|
||||
<span className="absolute top-1 ltr:right-2 rtl:left-2 min-w-4 h-4 px-1 rounded-full bg-rose-500 text-[9px] font-bold text-white grid place-items-center">
|
||||
{it.badge > 9 ? "9+" : it.badge}
|
||||
</span>
|
||||
)}
|
||||
<it.icon className={cn("size-5", active && "text-[#2a1f04]")} />
|
||||
<span className={cn("text-[10px] font-bold leading-none", active && "text-[#2a1f04]")}>
|
||||
{it.label}
|
||||
|
||||
@@ -77,13 +77,13 @@ export function PostMatchRewardsModal({
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-navy-950/85 backdrop-blur-sm p-5"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-navy-950/85 backdrop-blur-sm p-3 sm:p-5"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.82, y: 28 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 18 }}
|
||||
className="glass rounded-3xl p-7 w-full max-w-sm text-center relative max-h-[88dvh] overflow-y-auto overflow-x-hidden"
|
||||
className="glass rounded-3xl p-7 short:p-4 w-full max-w-sm landscape:max-w-md text-center relative max-h-[94dvh] overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
{/* radiating bg glow */}
|
||||
<div
|
||||
@@ -98,13 +98,13 @@ export function PostMatchRewardsModal({
|
||||
initial={{ scale: 0, rotate: -20 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", stiffness: 180, delay: 0.1 }}
|
||||
className="text-6xl mb-2 relative"
|
||||
className="text-6xl short:text-4xl mb-2 short:mb-0 relative"
|
||||
>
|
||||
{won ? "🏆" : "🎴"}
|
||||
</motion.div>
|
||||
|
||||
<h2 className="gold-text text-2xl font-black relative">{t("reward.title")}</h2>
|
||||
<p className={"relative mt-1 font-bold text-lg " + (won ? "text-teal-300" : "text-rose-300")}>
|
||||
<h2 className="gold-text text-2xl short:text-xl font-black relative">{t("reward.title")}</h2>
|
||||
<p className={"relative mt-1 font-bold text-lg short:text-base " + (won ? "text-teal-300" : "text-rose-300")}>
|
||||
{won ? t("reward.win") : t("reward.lose")}
|
||||
</p>
|
||||
|
||||
@@ -114,9 +114,9 @@ export function PostMatchRewardsModal({
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 14, delay: 0.18 }}
|
||||
className="relative mt-4 flex items-center justify-center gap-2"
|
||||
className="relative mt-4 short:mt-2 flex items-center justify-center gap-2"
|
||||
>
|
||||
<span className="text-5xl font-black gold-text">
|
||||
<span className="text-5xl short:text-3xl font-black gold-text">
|
||||
+<CountUp to={reward.coinsDelta} ms={1100} />
|
||||
</span>
|
||||
<motion.span
|
||||
@@ -128,7 +128,7 @@ export function PostMatchRewardsModal({
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="relative mt-5 space-y-2">
|
||||
<div className="relative mt-5 short:mt-3 space-y-2 short:space-y-1.5">
|
||||
{reward.ratingDelta !== 0 && (
|
||||
<RewardRow
|
||||
icon={reward.ratingDelta > 0
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight, MessageCircle, Send } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, MessageCircle, Send, Smile } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useOnlineStore } from "@/lib/online-store";
|
||||
import { useSessionStore } from "@/lib/session-store";
|
||||
import { useUIStore } from "@/lib/ui-store";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
import { sound } from "@/lib/sound";
|
||||
import { ownedReactions } from "@/lib/online/gamification";
|
||||
import { avatarEmoji } from "@/lib/online/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
@@ -16,10 +17,13 @@ export function ChatScreen() {
|
||||
const messages = useOnlineStore((s) => s.chatMessages);
|
||||
const sendChat = useOnlineStore((s) => s.sendChat);
|
||||
const closeChat = useOnlineStore((s) => s.closeChat);
|
||||
const isPro = useSessionStore((s) => s.profile?.plan === "pro");
|
||||
const profile = useSessionStore((s) => s.profile);
|
||||
const isPro = profile?.plan === "pro";
|
||||
const navBack = useUIStore((s) => s.back);
|
||||
const viewProfile = useUIStore((s) => s.viewProfile);
|
||||
const [text, setText] = useState("");
|
||||
const [showEmoji, setShowEmoji] = useState(false);
|
||||
const emojis = profile ? ownedReactions(profile) : [];
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
const prevLen = useRef(0);
|
||||
const Chevron = locale === "fa" ? ChevronRight : ChevronLeft;
|
||||
@@ -49,6 +53,11 @@ export function ChatScreen() {
|
||||
await sendChat(v);
|
||||
};
|
||||
|
||||
const sendEmoji = async (e: string) => {
|
||||
setShowEmoji(false);
|
||||
await sendChat(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="persian-pattern relative h-dvh w-full flex justify-center">
|
||||
<div className="w-full max-w-3xl flex flex-col h-full">
|
||||
@@ -106,21 +115,45 @@ export function ChatScreen() {
|
||||
</div>
|
||||
|
||||
{/* input */}
|
||||
<footer className="glass p-3 flex items-center gap-2 shrink-0">
|
||||
<input
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && send()}
|
||||
placeholder={t("chat.placeholder")}
|
||||
className="flex-1 rounded-full bg-navy-900/70 gold-border px-4 py-2.5 text-cream placeholder:text-cream/30 outline-none focus:ring-2 focus:ring-gold-500/40"
|
||||
/>
|
||||
<button
|
||||
onClick={send}
|
||||
className="btn-green tap grid place-items-center rounded-full shrink-0"
|
||||
aria-label={t("chat.send")}
|
||||
>
|
||||
<Send className="size-4 rtl:-scale-x-100" />
|
||||
</button>
|
||||
<footer className="glass p-3 shrink-0 relative">
|
||||
{/* emoji / sticker picker */}
|
||||
{showEmoji && emojis.length > 0 && (
|
||||
<div className="absolute bottom-full inset-x-3 mb-2 panel rounded-2xl p-2 grid grid-cols-8 gap-1 max-h-44 overflow-y-auto">
|
||||
{emojis.map((e, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => sendEmoji(e)}
|
||||
className="text-2xl rounded-lg p-1 hover:bg-navy-800/70 active:scale-90 transition"
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowEmoji((v) => !v)}
|
||||
className={cn("tap grid place-items-center rounded-full shrink-0 transition", showEmoji ? "btn-gold" : "text-gold-400 hover:bg-navy-800/70")}
|
||||
aria-label={t("chat.emoji")}
|
||||
>
|
||||
<Smile className="size-5" />
|
||||
</button>
|
||||
<input
|
||||
value={text}
|
||||
onFocus={() => setShowEmoji(false)}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && send()}
|
||||
placeholder={t("chat.placeholder")}
|
||||
className="flex-1 min-w-0 rounded-full bg-navy-900/70 gold-border px-4 py-2.5 text-cream placeholder:text-cream/30 outline-none focus:ring-2 focus:ring-gold-500/40"
|
||||
/>
|
||||
<button
|
||||
onClick={send}
|
||||
className="btn-green tap grid place-items-center rounded-full shrink-0"
|
||||
aria-label={t("chat.send")}
|
||||
>
|
||||
<Send className="size-4 rtl:-scale-x-100" />
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -42,12 +42,15 @@ type Tab = "friends" | "discover" | "messages";
|
||||
export function FriendsScreen() {
|
||||
const { t } = useI18n();
|
||||
const requests = useOnlineStore((s) => s.requests);
|
||||
const unread = useOnlineStore((s) => s.unread);
|
||||
const load = useOnlineStore((s) => s.loadFriends);
|
||||
const refreshUnread = useOnlineStore((s) => s.refreshUnread);
|
||||
const [tab, setTab] = useState<Tab>("friends");
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
refreshUnread();
|
||||
}, [load, refreshUnread]);
|
||||
|
||||
return (
|
||||
<ScreenShell>
|
||||
@@ -57,7 +60,7 @@ export function FriendsScreen() {
|
||||
<div className="panel rounded-2xl p-1 flex gap-1 mb-4">
|
||||
<TabButton active={tab === "friends"} onClick={() => setTab("friends")} icon={<Users className="size-4" />} label={t("social.tabFriends")} badge={requests.length} />
|
||||
<TabButton active={tab === "discover"} onClick={() => setTab("discover")} icon={<Search className="size-4" />} label={t("social.tabDiscover")} />
|
||||
<TabButton active={tab === "messages"} onClick={() => setTab("messages")} icon={<MessageCircle className="size-4" />} label={t("social.tabMessages")} />
|
||||
<TabButton active={tab === "messages"} onClick={() => setTab("messages")} icon={<MessageCircle className="size-4" />} label={t("social.tabMessages")} badge={unread} />
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
|
||||
+4
-2
@@ -151,6 +151,7 @@ const fa: Dict = {
|
||||
"chat.title": "گفتگو",
|
||||
"chat.placeholder": "پیام بنویسید…",
|
||||
"chat.send": "ارسال",
|
||||
"chat.emoji": "ایموجی",
|
||||
"chat.empty": "گفتگو را شروع کنید",
|
||||
"friends.message": "پیام",
|
||||
|
||||
@@ -315,7 +316,7 @@ const fa: Dict = {
|
||||
"shop.titles": "عناوین",
|
||||
"shop.titlesHint": "عنوان شما زیر نامتان در بازی و لیستها نمایش داده میشود",
|
||||
"shop.xp": "امتیاز تجربه (XP)",
|
||||
"shop.xpHint": "افزایش سریع سطح — XP گران است",
|
||||
"shop.xpHint": "افزایش سریع سطح",
|
||||
"shop.includes": "شامل",
|
||||
"shop.reqLevel": "سطح",
|
||||
"shop.reqRating": "امتیاز",
|
||||
@@ -501,6 +502,7 @@ const en: Dict = {
|
||||
"chat.title": "Chat",
|
||||
"chat.placeholder": "Type a message…",
|
||||
"chat.send": "Send",
|
||||
"chat.emoji": "Emoji",
|
||||
"chat.empty": "Start the conversation",
|
||||
"friends.message": "Message",
|
||||
|
||||
@@ -662,7 +664,7 @@ const en: Dict = {
|
||||
"shop.titles": "Titles",
|
||||
"shop.titlesHint": "Your title shows under your name in games & lists",
|
||||
"shop.xp": "XP packs",
|
||||
"shop.xpHint": "Level up faster — XP is expensive",
|
||||
"shop.xpHint": "Level up faster",
|
||||
"shop.includes": "Includes",
|
||||
"shop.reqLevel": "Level",
|
||||
"shop.reqRating": "Rating",
|
||||
|
||||
@@ -41,9 +41,11 @@ interface OnlineStore {
|
||||
// chat
|
||||
activeChatFriend: Friend | null;
|
||||
chatMessages: ChatMessage[];
|
||||
unread: number; // total unread messages across conversations (for nav badge)
|
||||
openChat: (friend: Friend) => Promise<void>;
|
||||
sendChat: (text: string) => Promise<void>;
|
||||
closeChat: () => void;
|
||||
refreshUnread: () => Promise<void>;
|
||||
}
|
||||
|
||||
let roomUnsub: (() => void) | null = null;
|
||||
@@ -154,11 +156,22 @@ export const useOnlineStore = create<OnlineStore>((set, get) => ({
|
||||
|
||||
activeChatFriend: null,
|
||||
chatMessages: [],
|
||||
unread: 0,
|
||||
|
||||
refreshUnread: async () => {
|
||||
try {
|
||||
const convs = await getService().listConversations();
|
||||
set({ unread: convs.reduce((n, c) => n + (c.unread ?? 0), 0) });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
openChat: async (friend) => {
|
||||
const svc = getService();
|
||||
set({ activeChatFriend: friend, chatMessages: await svc.getMessages(friend.id) });
|
||||
await svc.markRead(friend.id);
|
||||
get().refreshUnread();
|
||||
if (chatUnsub) chatUnsub();
|
||||
chatUnsub = svc.onChat((friendId, msgs) => {
|
||||
const active = get().activeChatFriend;
|
||||
@@ -182,5 +195,6 @@ export const useOnlineStore = create<OnlineStore>((set, get) => ({
|
||||
chatUnsub = null;
|
||||
}
|
||||
set({ activeChatFriend: null, chatMessages: [] });
|
||||
get().refreshUnread();
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user