([]);
- const list = profile ? ownedReactions(profile) : [];
+ const emojis = profile ? ownedReactions(profile) : [];
+ const stickers = profile ? ownedStickers(profile) : [];
useEffect(() => {
const unsub = getService().onReaction((seat, emoji) => {
@@ -508,8 +518,8 @@ function Reactions() {
return unsub;
}, []);
- const send = (emoji: string) => {
- getService().sendReaction(emoji);
+ const send = (value: string) => {
+ getService().sendReaction(value);
setOpen(false);
};
@@ -524,7 +534,7 @@ function Reactions() {
exit={{ opacity: 0 }}
className={cn("absolute z-40 pointer-events-none", REACTION_POS[b.seat])}
>
- {b.emoji}
+
))}
@@ -535,17 +545,54 @@ function Reactions() {
initial={{ opacity: 0, y: 12, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 12, scale: 0.95 }}
- className="absolute bottom-20 ltr:right-4 rtl:left-4 z-50 glass rounded-2xl p-2 grid grid-cols-5 gap-1 max-w-[260px]"
+ className="absolute bottom-20 ltr:right-4 rtl:left-4 z-50 glass rounded-2xl p-2 w-[270px]"
>
- {list.map((emoji, i) => (
+
- ))}
+
+
+
+ {tab === "emoji" ? (
+
+ {emojis.map((emoji, i) => (
+
+ ))}
+
+ ) : (
+
+ {stickers.map((id) => (
+
+ ))}
+
+ )}
)}
diff --git a/src/components/online/Sticker.tsx b/src/components/online/Sticker.tsx
new file mode 100644
index 0000000..8b2b492
--- /dev/null
+++ b/src/components/online/Sticker.tsx
@@ -0,0 +1,202 @@
+"use client";
+
+/**
+ * Hand-designed sticker artwork as inline SVG — no external assets.
+ * Each sticker is keyed by id; packs (see gamification.ts) reference these ids.
+ */
+
+type SvgProps = { className?: string };
+
+function Face({
+ bg1,
+ bg2,
+ children,
+}: {
+ bg1: string;
+ bg2: string;
+ children: React.ReactNode;
+}) {
+ return (
+ <>
+
+
+
+
+
+
+
+ {children}
+ >
+ );
+}
+
+const STICKERS: Record = {
+ /* ----------------------------- faces ----------------------------- */
+ happy: (
+
+
+
+
+
+ ),
+ sad: (
+
+
+
+
+
+
+ ),
+ cool: (
+
+
+
+
+
+
+ ),
+ love: (
+
+
+
+
+
+ ),
+ angry: (
+
+
+
+
+
+
+
+ ),
+
+ /* ------------------------------ hokm ----------------------------- */
+ "hokm-badge": (
+ <>
+
+
+
+
+
+
+
+
+
+
+ حکم
+
+
+ ♠
+
+ >
+ ),
+ "kot-stamp": (
+
+
+
+
+ کُت!
+
+
+ ),
+ crown: (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ),
+ "ace-spade": (
+ <>
+
+ A
+ ♠
+ A
+ >
+ ),
+
+ /* ----------------------------- persian --------------------------- */
+ chai: (
+ <>
+
+
+
+
+
+ >
+ ),
+ afarin: (
+ <>
+
+
+
+
+
+
+
+
+ آفرین
+
+ >
+ ),
+ rose: (
+ <>
+
+
+
+
+
+ >
+ ),
+
+ /* ------------------------------ taunt ---------------------------- */
+ clown: (
+
+
+
+
+
+
+
+
+ ),
+ sleep: (
+
+
+
+ z
+ z
+
+ ),
+ weak: (
+ <>
+
+
+
+ ضعیف!
+
+ >
+ ),
+};
+
+export const STICKER_IDS = Object.keys(STICKERS);
+
+export function Sticker({ id, size = 64, className }: { id: string; size?: number } & SvgProps) {
+ const art = STICKERS[id];
+ if (!art) return null;
+ return (
+
+ );
+}
diff --git a/src/components/screens/ShopScreen.tsx b/src/components/screens/ShopScreen.tsx
index 6377859..7d0c5d1 100644
--- a/src/components/screens/ShopScreen.tsx
+++ b/src/components/screens/ShopScreen.tsx
@@ -3,6 +3,7 @@
import { Check, Coins } from "lucide-react";
import { useEffect, useState } from "react";
import { ScreenHeader, ScreenShell } from "@/components/online/ScreenHeader";
+import { Sticker } from "@/components/online/Sticker";
import { useSessionStore } from "@/lib/session-store";
import { useI18n } from "@/lib/i18n";
import { getService } from "@/lib/online/service";
@@ -27,7 +28,9 @@ export function ShopScreen() {
? profile.ownedAvatars.includes(item.id)
: item.kind === "cardstyle"
? profile.ownedCardStyles.includes(item.id)
- : profile.ownedReactionPacks.includes(item.id);
+ : item.kind === "reactionpack"
+ ? profile.ownedReactionPacks.includes(item.id)
+ : profile.ownedStickerPacks.includes(item.id);
const buy = async (item: ShopItem) => {
const res = await getService().buyItem(item.id);
@@ -42,6 +45,7 @@ export function ShopScreen() {
const avatars = items.filter((i) => i.kind === "avatar");
const cardstyles = items.filter((i) => i.kind === "cardstyle");
const reactions = items.filter((i) => i.kind === "reactionpack");
+ const stickers = items.filter((i) => i.kind === "stickerpack");
return (
@@ -102,6 +106,20 @@ export function ShopScreen() {
))}
+
+
+
+ {stickers.map((item) => (
+ buy(item)}
+ preview={}
+ />
+ ))}
+
+
);
}
diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx
index ec81498..b63c386 100644
--- a/src/lib/i18n.tsx
+++ b/src/lib/i18n.tsx
@@ -219,9 +219,11 @@ const fa: Dict = {
"shop.cardstyles": "طرح کارتها",
"shop.reactions": "بسته شکلکها",
+ "shop.stickers": "بسته استیکرها",
"reward.newTitle": "عنوان جدید",
- "reactions.title": "شکلکها",
+ "reactions.title": "شکلک",
+ "stickers.title": "استیکر",
};
const en: Dict = {
@@ -430,9 +432,11 @@ const en: Dict = {
"shop.cardstyles": "Card styles",
"shop.reactions": "Reaction packs",
+ "shop.stickers": "Sticker packs",
"reward.newTitle": "New title",
- "reactions.title": "Reactions",
+ "reactions.title": "Emoji",
+ "stickers.title": "Stickers",
};
const DICTS: Record = { fa, en };
diff --git a/src/lib/online/gamification.ts b/src/lib/online/gamification.ts
index bfad04f..75fdabf 100644
--- a/src/lib/online/gamification.ts
+++ b/src/lib/online/gamification.ts
@@ -12,6 +12,7 @@ import {
RankTierId,
ReactionPackDef,
RewardResult,
+ StickerPackDef,
TitleDef,
TitleUnlock,
UserProfile,
@@ -262,6 +263,37 @@ export function ownedReactions(profile: UserProfile): string[] {
return REACTION_PACKS.filter((p) => ids.has(p.id)).flatMap((p) => p.reactions);
}
+/* ------------------------- Sticker packs ----------------------------- */
+
+export const STICKER_PACKS: StickerPackDef[] = [
+ { id: "faces", nameFa: "شکلکها", nameEn: "Faces", stickers: ["happy", "sad", "cool", "love", "angry"], price: 0, default: true },
+ { id: "hokm", nameFa: "حکم", nameEn: "Hokm", stickers: ["hokm-badge", "kot-stamp", "crown", "ace-spade"], price: 0, unlockRating: 1300 },
+ { id: "persian", nameFa: "ایرانی", nameEn: "Persian", stickers: ["chai", "afarin", "rose"], price: 700 },
+ { id: "taunt", nameFa: "طعنه", nameEn: "Taunts", stickers: ["clown", "sleep", "weak"], price: 900 },
+];
+
+export function stickerPackById(id: string): StickerPackDef | undefined {
+ return STICKER_PACKS.find((p) => p.id === id);
+}
+
+export function ownedStickerPackIds(profile: UserProfile): string[] {
+ const purchased = profile.ownedStickerPacks ?? [];
+ const ids = new Set();
+ for (const p of STICKER_PACKS) {
+ const earned =
+ (p.unlockRating != null && profile.rating >= p.unlockRating) ||
+ (p.unlockWins != null && profile.stats.wins >= p.unlockWins);
+ if (p.default || earned || purchased.includes(p.id)) ids.add(p.id);
+ }
+ return [...ids];
+}
+
+/** Flattened sticker-id list the player can send. */
+export function ownedStickers(profile: UserProfile): string[] {
+ const ids = new Set(ownedStickerPackIds(profile));
+ return STICKER_PACKS.filter((p) => ids.has(p.id)).flatMap((p) => p.stickers);
+}
+
/* ---------------------- Apply a match result ------------------------- */
function applyStats(stats: PlayerStats, summary: MatchSummary): PlayerStats {
diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts
index 3cb0eeb..f3da261 100644
--- a/src/lib/online/mock-service.ts
+++ b/src/lib/online/mock-service.ts
@@ -2,7 +2,13 @@
// Simulates remote players, friends presence, room invites and matchmaking
// with timers, and computes rewards via gamification.ts.
-import { CARD_STYLES, REACTION_PACKS, applyMatchResult, dailyRewardFor } from "./gamification";
+import {
+ CARD_STYLES,
+ REACTION_PACKS,
+ STICKER_PACKS,
+ applyMatchResult,
+ dailyRewardFor,
+} from "./gamification";
import {
CreateRoomOptions,
MatchmakingOptions,
@@ -113,6 +119,7 @@ function defaultProfile(session: AuthSession): UserProfile {
ownedCardStyles: ["classic"],
ownedTitles: ["novice"],
ownedReactionPacks: [],
+ ownedStickerPacks: [],
title: "novice",
cardStyle: "classic",
achievements: {},
@@ -130,6 +137,7 @@ function migrateProfile(p: UserProfile): UserProfile {
ownedCardStyles: p.ownedCardStyles ?? ["classic"],
ownedTitles: p.ownedTitles ?? ["novice"],
ownedReactionPacks: p.ownedReactionPacks ?? [],
+ ownedStickerPacks: p.ownedStickerPacks ?? [],
title: p.title ?? "novice",
cardStyle: p.cardStyle ?? "classic",
};
@@ -452,7 +460,10 @@ export class MockOnlineService implements OnlineService {
onReaction(cb: (seat: number, reaction: string) => void): Unsubscribe {
this.reactionCbs.add(cb);
if (this.reactionTimer == null) {
- const pool = ["👍", "😂", "🔥", "😮", "👏", "😎", "🙄", "😭"];
+ const pool = [
+ "👍", "😂", "🔥", "😮", "👏", "🙄",
+ "sticker:happy", "sticker:cool", "sticker:kot-stamp", "sticker:crown",
+ ];
this.reactionTimer = setInterval(() => {
if (this.reactionCbs.size === 0) return;
const seat = randInt(1, 3);
@@ -761,7 +772,15 @@ export class MockOnlineService implements OnlineService {
price: r.price,
preview: r.reactions[0],
}));
- return [...avatarItems, ...cardItems, ...reactionItems];
+ const stickerItems: ShopItem[] = STICKER_PACKS.filter((p) => p.price > 0).map((p) => ({
+ id: p.id,
+ kind: "stickerpack",
+ nameFa: p.nameFa,
+ nameEn: p.nameEn,
+ price: p.price,
+ preview: p.stickers[0], // sticker id; ShopScreen renders via
+ }));
+ return [...avatarItems, ...cardItems, ...reactionItems, ...stickerItems];
}
async buyItem(id: string) {
@@ -774,7 +793,9 @@ export class MockOnlineService implements OnlineService {
? p.ownedAvatars.includes(id)
: item.kind === "cardstyle"
? p.ownedCardStyles.includes(id)
- : p.ownedReactionPacks.includes(id);
+ : item.kind === "reactionpack"
+ ? p.ownedReactionPacks.includes(id)
+ : p.ownedStickerPacks.includes(id);
if (owned) return { ok: false, messageFa: "قبلاً خریداری شده", messageEn: "Already owned" };
if (p.coins < item.price)
return { ok: false, messageFa: "سکه کافی نیست", messageEn: "Not enough coins" };
@@ -787,6 +808,8 @@ export class MockOnlineService implements OnlineService {
item.kind === "cardstyle" ? [...p.ownedCardStyles, id] : p.ownedCardStyles,
ownedReactionPacks:
item.kind === "reactionpack" ? [...p.ownedReactionPacks, id] : p.ownedReactionPacks,
+ ownedStickerPacks:
+ item.kind === "stickerpack" ? [...p.ownedStickerPacks, id] : p.ownedStickerPacks,
};
this.saveProfile();
return { ok: true, profile: this.profile, messageFa: "خرید انجام شد", messageEn: "Purchased" };
diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts
index f808984..e87b009 100644
--- a/src/lib/online/types.ts
+++ b/src/lib/online/types.ts
@@ -55,6 +55,7 @@ export interface UserProfile {
ownedCardStyles: string[];
ownedTitles: string[];
ownedReactionPacks: string[]; // purchased reaction packs
+ ownedStickerPacks: string[]; // purchased sticker packs
title: string | null; // equipped title id
cardStyle: string; // equipped card-back style id
@@ -146,6 +147,17 @@ export interface ReactionPackDef {
unlockWins?: number; // earned by total wins
}
+export interface StickerPackDef {
+ id: string;
+ nameFa: string;
+ nameEn: string;
+ stickers: string[]; // Sticker artwork ids (see components/online/Sticker.tsx)
+ price: number; // >0 → purchasable in the shop
+ default?: boolean;
+ unlockRating?: number;
+ unlockWins?: number;
+}
+
/* ------------------------------ Friends ------------------------------ */
export type PresenceStatus = "online" | "offline" | "in-game";
@@ -281,7 +293,7 @@ export interface LeaderboardEntry {
/* ------------------------------- Shop -------------------------------- */
-export type ShopItemKind = "avatar" | "cardstyle" | "reactionpack";
+export type ShopItemKind = "avatar" | "cardstyle" | "reactionpack" | "stickerpack";
export interface ShopItem {
id: string;