cb27a16dc1
Game table & play - UNO-style restyle: suit-aware bolder cards (+xl size), pulsing playable glow, big "YOUR TURN" pill, active-seat ring, trick-win particle burst, round confetti, match coin-rain. - Per-league turn time via turnMsForStake: 15s starter/AI, 10s pro, 7s expert; mirrored server-side in GameRoom.TurnMs. - Speed (Blitz) mode for vs-AI/private: 5s turns, race to 5, ~halved pacing. - Matchmaking waits ~15s (randomized 12-18s) then fills bots; elapsed timer + hint. Rewards / gifts - Richer post-match modal (floating coins, XP bar), celebration overlay reveals the unlocked sticker pack, boosted daily rewards (client+server synced), themed 7-day daily with special day-7. Social - Public profile modal (identity, stats, achievement board) from leaderboard / friends / discover / end-of-game roster; rate-limited add-friend (10/hour). - Social hub: Friends / Discover (player search + suggestions) / Messages inbox. - Profile gender (shown in finder/profile) + social links with public/friends/ hidden visibility, enforced server-side. Cosmetics - Distinct card backs: per-design pattern families (stripes/argyle/grid/dots/ rays/scales/crosshatch/royal/filigree/gem) + luxury motifs (lib/cardBack.ts), consistent on table/shop/profile; +Peacock/Rose-Gold backs. - Purchasable titles (shop Titles section); title shown under the seat on the table and in discover/public profile. - 10 new sticker packs (banter/kol-kol, Persian trends, court cards, moods). - Persistent level+XP bar on Home and every inner screen. Payments - Buy-coins gateway opens in a new tab (no SPA dead-end) + focus refresh. - Store IAB scaffolding: Cafe Bazaar deep-link purchase + redirect-token capture, Myket native-bridge contract, server-side IabService.Verify for both stores, config-driven via Iab__* env. POST /api/coins/iab/verify (JWT). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
213 lines
7.7 KiB
TypeScript
213 lines
7.7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect } from "react";
|
|
import { MotionConfig } from "framer-motion";
|
|
import { HomeScreen } from "@/components/HomeScreen";
|
|
import { GameScreen } from "@/components/screens/GameScreen";
|
|
import { ProfileScreen } from "@/components/screens/ProfileScreen";
|
|
import { FriendsScreen } from "@/components/screens/FriendsScreen";
|
|
import { OnlineLobbyScreen } from "@/components/screens/OnlineLobbyScreen";
|
|
import { RoomScreen } from "@/components/screens/RoomScreen";
|
|
import { MatchmakingScreen } from "@/components/screens/MatchmakingScreen";
|
|
import { LeaderboardScreen } from "@/components/screens/LeaderboardScreen";
|
|
import { ShopScreen } from "@/components/screens/ShopScreen";
|
|
import { BuyCoinsScreen } from "@/components/screens/BuyCoinsScreen";
|
|
import { AchievementsScreen } from "@/components/screens/AchievementsScreen";
|
|
import { ChatScreen } from "@/components/screens/ChatScreen";
|
|
import { NotificationsScreen } from "@/components/screens/NotificationsScreen";
|
|
import { AuthScreen } from "@/components/screens/AuthScreen";
|
|
import { DailyRewardModal } from "@/components/online/DailyRewardModal";
|
|
import { NotificationToaster } from "@/components/online/NotificationToaster";
|
|
import { ResumeGameBar } from "@/components/online/ResumeGameBar";
|
|
import { CelebrationOverlay } from "@/components/online/CelebrationOverlay";
|
|
import { PublicProfileModal } from "@/components/online/PublicProfileModal";
|
|
import { CapacitorBack } from "@/components/CapacitorBack";
|
|
import { useSessionStore } from "@/lib/session-store";
|
|
import { useGameStore } from "@/lib/game-store";
|
|
import { useOnlineStore } from "@/lib/online-store";
|
|
import { useNotifStore, pushNotification } from "@/lib/notification-store";
|
|
import { getService } from "@/lib/online/service";
|
|
import { captureBazaarRedirect } from "@/lib/storeBilling";
|
|
import { screenFromHash, useUIStore, type Screen } from "@/lib/ui-store";
|
|
|
|
/** Transient screens can't be restored without their state — fall back to home. */
|
|
function resolveScreen(s: Screen): Screen {
|
|
switch (s) {
|
|
case "game":
|
|
return useGameStore.getState().started ? s : "home";
|
|
case "room":
|
|
return useOnlineStore.getState().room ? s : "home";
|
|
case "chat":
|
|
return useOnlineStore.getState().activeChatFriend ? s : "home";
|
|
case "matchmaking":
|
|
return useOnlineStore.getState().matchmaking.phase !== "idle" ? s : "home";
|
|
default:
|
|
return s;
|
|
}
|
|
}
|
|
|
|
export default function Page() {
|
|
const screen = useUIStore((s) => s.screen);
|
|
const init = useSessionStore((s) => s.init);
|
|
const loading = useSessionStore((s) => s.loading);
|
|
|
|
useEffect(() => {
|
|
init();
|
|
|
|
// ZarinPal payment return (?pay=success&coins= / ?pay=failed)
|
|
const params = new URLSearchParams(window.location.search);
|
|
const pay = params.get("pay");
|
|
if (pay) {
|
|
if (pay === "success") {
|
|
const coins = params.get("coins");
|
|
pushNotification({
|
|
kind: "system",
|
|
titleFa: "پرداخت موفق",
|
|
titleEn: "Payment successful",
|
|
bodyFa: coins ? `${coins} سکه به حساب شما اضافه شد` : undefined,
|
|
bodyEn: coins ? `${coins} coins added` : undefined,
|
|
icon: "💰",
|
|
});
|
|
useSessionStore.getState().refreshProfile();
|
|
} else {
|
|
pushNotification({ kind: "system", titleFa: "پرداخت ناموفق بود", titleEn: "Payment failed", icon: "⚠️" });
|
|
}
|
|
window.history.replaceState({}, "", window.location.pathname);
|
|
}
|
|
|
|
// Cafe Bazaar in-app purchase return (?purchaseToken=...) → verify + credit.
|
|
const iab = captureBazaarRedirect();
|
|
if (iab && iab.productId) {
|
|
getService()
|
|
.verifyIab(iab.store, iab.productId, iab.token)
|
|
.then((v) => {
|
|
if (v.ok) {
|
|
if (v.profile) useSessionStore.setState({ profile: v.profile });
|
|
pushNotification({
|
|
kind: "system",
|
|
titleFa: "پرداخت موفق",
|
|
titleEn: "Payment successful",
|
|
bodyFa: v.coins ? `${v.coins.toLocaleString()} سکه اضافه شد` : undefined,
|
|
bodyEn: v.coins ? `${v.coins.toLocaleString()} coins added` : undefined,
|
|
icon: "💰",
|
|
});
|
|
} else {
|
|
pushNotification({ kind: "system", titleFa: "پرداخت ناموفق بود", titleEn: "Payment failed", icon: "⚠️" });
|
|
}
|
|
})
|
|
.finally(() => window.history.replaceState({}, "", window.location.pathname));
|
|
}
|
|
|
|
useUIStore.getState().initHistory();
|
|
useNotifStore.getState().init();
|
|
// surface a daily-reward notification if it's available
|
|
getService()
|
|
.getDailyState()
|
|
.then((d) => {
|
|
if (d.available)
|
|
pushNotification({
|
|
kind: "daily",
|
|
titleFa: "پاداش روزانه آماده است",
|
|
titleEn: "Daily reward is ready",
|
|
icon: "🎁",
|
|
});
|
|
})
|
|
.catch(() => {});
|
|
|
|
// Resume an in-progress server match after a full reload: the server
|
|
// re-broadcasts state on (re)connect — if we're not already in a game and
|
|
// not mid-matchmaking, re-enter live mode (minimized) so the resume pill shows.
|
|
const svc = getService();
|
|
let resumeUnsub: (() => void) | undefined;
|
|
let rewardUnsub: (() => void) | undefined;
|
|
if (svc.live) {
|
|
resumeUnsub = svc.onState((s) => {
|
|
const gs = useGameStore.getState();
|
|
const scr = useUIStore.getState().screen;
|
|
if (
|
|
!gs.started &&
|
|
scr !== "matchmaking" &&
|
|
scr !== "game" &&
|
|
s.matchWinner == null &&
|
|
s.phase !== "match-over"
|
|
) {
|
|
gs.enterServerMatch(svc);
|
|
gs.applyServerState(s);
|
|
gs.minimize();
|
|
}
|
|
});
|
|
// Nudge the player when a match they left finishes while they're away.
|
|
rewardUnsub = svc.onReward(() => {
|
|
if (useUIStore.getState().screen !== "game")
|
|
pushNotification({
|
|
kind: "system",
|
|
titleFa: "بازی به پایان رسید",
|
|
titleEn: "Match ended",
|
|
bodyFa: "نتیجه و جایزه را ببینید",
|
|
bodyEn: "See the result and reward",
|
|
icon: "🏆",
|
|
});
|
|
});
|
|
}
|
|
|
|
const onPop = (e: PopStateEvent) => {
|
|
const raw = ((e.state?.screen as Screen) ?? screenFromHash());
|
|
useUIStore.getState().syncFromPop(resolveScreen(raw));
|
|
};
|
|
window.addEventListener("popstate", onPop);
|
|
return () => {
|
|
window.removeEventListener("popstate", onPop);
|
|
resumeUnsub?.();
|
|
rewardUnsub?.();
|
|
};
|
|
}, [init]);
|
|
|
|
return (
|
|
// reducedMotion="user" makes every Framer Motion animation honor the OS
|
|
// "reduce motion" accessibility setting (coin rain, confetti, count-ups…).
|
|
<MotionConfig reducedMotion="user">
|
|
{renderScreen(screen)}
|
|
<DailyRewardModal />
|
|
<NotificationToaster />
|
|
<ResumeGameBar />
|
|
<CelebrationOverlay />
|
|
<PublicProfileModal />
|
|
<CapacitorBack />
|
|
{loading && null}
|
|
</MotionConfig>
|
|
);
|
|
}
|
|
|
|
function renderScreen(screen: string) {
|
|
switch (screen) {
|
|
case "game":
|
|
return <GameScreen />;
|
|
case "auth":
|
|
return <AuthScreen />;
|
|
case "profile":
|
|
return <ProfileScreen />;
|
|
case "friends":
|
|
return <FriendsScreen />;
|
|
case "online":
|
|
return <OnlineLobbyScreen />;
|
|
case "room":
|
|
return <RoomScreen />;
|
|
case "matchmaking":
|
|
return <MatchmakingScreen />;
|
|
case "leaderboard":
|
|
return <LeaderboardScreen />;
|
|
case "shop":
|
|
return <ShopScreen />;
|
|
case "buycoins":
|
|
return <BuyCoinsScreen />;
|
|
case "achievements":
|
|
return <AchievementsScreen />;
|
|
case "chat":
|
|
return <ChatScreen />;
|
|
case "notifications":
|
|
return <NotificationsScreen />;
|
|
default:
|
|
return <HomeScreen />;
|
|
}
|
|
}
|