From 82b2bc064868cfae3cce55c422ea7298e9c4ec4c Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Sat, 6 Jun 2026 22:26:28 +0330 Subject: [PATCH] Polish: daily reward via celebration overlay + premium chat to recipient - Daily reward now routes through the global CelebrationOverlay: new "daily" variant + coins count-up; claiming closes the daily modal and fires celebrate({variant:"daily", coins}). Unifies the "you earned X" moment. - Premium (pro) gold chat is now visible to the OTHER player: ChatMessage gains senderPro; server resolves each participant's plan once (SocialService.IsPro) and stamps it on ChatMessageDto; ChatScreen styles incoming bubbles with .premium-chat when senderPro. Mock marks ~half its friends pro so it's visible offline too. Verified: tsc + next build + dotnet build all pass. Co-Authored-By: Claude Opus 4.8 --- server/src/Hokm.Server/Social/SocialModels.cs | 1 + server/src/Hokm.Server/Social/SocialService.cs | 18 +++++++++++++++--- src/components/online/CelebrationOverlay.tsx | 16 +++++++++++++++- src/components/online/DailyRewardModal.tsx | 5 ++++- src/components/screens/ChatScreen.tsx | 2 +- src/lib/celebration-store.ts | 4 +++- src/lib/online/mock-service.ts | 4 ++++ src/lib/online/types.ts | 2 ++ 8 files changed, 45 insertions(+), 7 deletions(-) diff --git a/server/src/Hokm.Server/Social/SocialModels.cs b/server/src/Hokm.Server/Social/SocialModels.cs index bfd6c2d..b37a143 100644 --- a/server/src/Hokm.Server/Social/SocialModels.cs +++ b/server/src/Hokm.Server/Social/SocialModels.cs @@ -24,6 +24,7 @@ public class ChatMessageDto public bool FromMe { get; set; } public string Text { get; set; } = ""; public long Ts { get; set; } + public bool SenderPro { get; set; } } public class ConversationDto diff --git a/server/src/Hokm.Server/Social/SocialService.cs b/server/src/Hokm.Server/Social/SocialService.cs index 8f204e1..cabf721 100644 --- a/server/src/Hokm.Server/Social/SocialService.cs +++ b/server/src/Hokm.Server/Social/SocialService.cs @@ -265,7 +265,9 @@ public class SocialService .OrderBy(m => m.CreatedAt).ToListAsync(); var unread = msgs.Where(m => m.UserId == peerId && m.PeerId == uid && !m.ReadByPeer).ToList(); if (unread.Count > 0) { unread.ForEach(m => m.ReadByPeer = true); await _db.SaveChangesAsync(); } - return msgs.Select(m => ToDto(m, uid)).ToList(); + // Resolve each participant's plan once so premium (pro) senders show gold. + bool uidPro = await IsPro(uid), peerPro = await IsPro(peerId); + return msgs.Select(m => ToDto(m, uid, m.UserId == uid ? uidPro : peerPro)).ToList(); } public async Task Send(string uid, string peerId, string text) @@ -274,14 +276,24 @@ public class SocialService _db.Messages.Add(m); await _db.SaveChangesAsync(); await _hub.Clients.User(peerId).SendAsync("chat", new { peerId = uid }); - return ToDto(m, uid); + return ToDto(m, uid, await IsPro(uid)); } - private static ChatMessageDto ToDto(MessageRow m, string uid) => new() + /// True when the user has an active premium (pro) plan. + private async Task IsPro(string userId) + { + var row = await _db.Profiles.FindAsync(userId); + if (row == null) return false; + var p = JsonSerializer.Deserialize(row.Json, JsonOpts.Default); + return p?.Plan == "pro"; + } + + private static ChatMessageDto ToDto(MessageRow m, string uid, bool senderPro = false) => new() { Id = m.Id.ToString(), FromMe = m.UserId == uid, Text = m.Text, Ts = new DateTimeOffset(m.CreatedAt).ToUnixTimeMilliseconds(), + SenderPro = senderPro, }; } diff --git a/src/components/online/CelebrationOverlay.tsx b/src/components/online/CelebrationOverlay.tsx index 0269556..8e3b033 100644 --- a/src/components/online/CelebrationOverlay.tsx +++ b/src/components/online/CelebrationOverlay.tsx @@ -56,6 +56,7 @@ function Card() { const dismiss = useCelebrationStore((s) => s.dismiss); const leveled = (current.levelAfter ?? 0) > (current.levelBefore ?? 0); const xp = useCountUp(current.xpGained ?? 0, 900, current.variant === "xp"); + const coins = useCountUp(current.coins ?? 0, 1000, (current.coins ?? 0) > 0); return ( - {current.icon ?? (current.variant === "xp" ? "⚡" : "🛍️")} + {current.icon ?? (current.variant === "xp" ? "⚡" : current.variant === "daily" ? "🎁" : "🛍️")} {current.title && (

{current.title}

)} + {/* coins gained (daily reward, etc.) */} + {(current.coins ?? 0) > 0 && ( + + + +{coins.toLocaleString()} + + )} + {/* XP gain */} {current.variant === "xp" && (current.xpGained ?? 0) > 0 && (
diff --git a/src/components/online/DailyRewardModal.tsx b/src/components/online/DailyRewardModal.tsx index d726276..f05a53c 100644 --- a/src/components/online/DailyRewardModal.tsx +++ b/src/components/online/DailyRewardModal.tsx @@ -8,6 +8,7 @@ import { useUIStore } from "@/lib/ui-store"; import { useI18n } from "@/lib/i18n"; import { DAILY_REWARDS } from "@/lib/online/gamification"; import { getService } from "@/lib/online/service"; +import { celebrate } from "@/lib/celebration-store"; import { sound } from "@/lib/sound"; import { DailyRewardState } from "@/lib/online/types"; import { cn } from "@/lib/cn"; @@ -48,7 +49,9 @@ export function DailyRewardModal() { setClaimed(res.reward); sound.play("award"); await refreshProfile(); - setState(await getService().getDailyState()); + // Hand off to the global celebration overlay (animated coin count-up). + close(); + celebrate({ variant: "daily", title: t("daily.title"), coins: res.reward }); }; const isMegaDay = state?.day === 7; diff --git a/src/components/screens/ChatScreen.tsx b/src/components/screens/ChatScreen.tsx index 52436ae..84b92a1 100644 --- a/src/components/screens/ChatScreen.tsx +++ b/src/components/screens/ChatScreen.tsx @@ -95,7 +95,7 @@ export function ChatScreen() { "max-w-[78%] rounded-2xl px-3.5 py-2 text-sm", m.fromMe ? cn("ms-auto rounded-ee-sm", isPro ? "premium-chat" : "btn-gold") - : "me-auto glass text-cream rounded-es-sm" + : cn("me-auto rounded-es-sm", m.senderPro ? "premium-chat" : "glass text-cream") )} > {m.text} diff --git a/src/lib/celebration-store.ts b/src/lib/celebration-store.ts index 2fadcc9..44baeaf 100644 --- a/src/lib/celebration-store.ts +++ b/src/lib/celebration-store.ts @@ -6,11 +6,13 @@ import { AchievementUnlock } from "./online/types"; /** A queued celebration shown by . */ export interface Celebration { id: number; - variant: "xp" | "purchase"; + variant: "xp" | "purchase" | "daily"; title?: string; /** emoji/glyph shown big at the top */ icon?: string; xpGained?: number; + /** coins gained (daily reward, etc.) — shown with a count-up */ + coins?: number; levelBefore?: number; levelAfter?: number; achievements?: AchievementUnlock[]; diff --git a/src/lib/online/mock-service.ts b/src/lib/online/mock-service.ts index 624b567..6d084d9 100644 --- a/src/lib/online/mock-service.ts +++ b/src/lib/online/mock-service.ts @@ -616,11 +616,14 @@ export class MockOnlineService implements OnlineService { fromMe: true, text: text.trim(), ts: Date.now(), + senderPro: this.profile?.plan === "pro", }; this.messages[friendId] = [...(this.messages[friendId] ?? []), msg]; this.saveChats(); this.emitChat(friendId); + // deterministic: ~half of mock friends are "pro" so the gold bubble is visible offline + const friendPro = [...friendId].reduce((a, c) => a + c.charCodeAt(0), 0) % 2 === 0; // simulate a reply from the friend this.after(randInt(900, 1900), () => { const reply: ChatMessage = { @@ -628,6 +631,7 @@ export class MockOnlineService implements OnlineService { fromMe: false, text: pick(CANNED_REPLIES), ts: Date.now(), + senderPro: friendPro, }; this.messages[friendId] = [...(this.messages[friendId] ?? []), reply]; this.unread[friendId] = (this.unread[friendId] ?? 0) + 1; diff --git a/src/lib/online/types.ts b/src/lib/online/types.ts index cd808ae..91d0138 100644 --- a/src/lib/online/types.ts +++ b/src/lib/online/types.ts @@ -497,6 +497,8 @@ export interface ChatMessage { fromMe: boolean; text: string; ts: number; + /** the sender has a premium (pro) plan → show gold/animated bubble */ + senderPro?: boolean; } export interface Conversation {