UX: landscape result screen, chat emojis, unread badges, remove XP text
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 4m33s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m7s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 55s

- 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:
soroush.asadi
2026-06-11 14:58:43 +03:30
parent 24a2c251ad
commit deb83cf77c
6 changed files with 104 additions and 32 deletions
+23 -3
View File
@@ -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
+50 -17
View File
@@ -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>
+5 -2
View File
@@ -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
View File
@@ -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",
+14
View File
@@ -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();
},
}));