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 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,7 @@ public class ChatMessageDto
|
|||||||
public bool FromMe { get; set; }
|
public bool FromMe { get; set; }
|
||||||
public string Text { get; set; } = "";
|
public string Text { get; set; } = "";
|
||||||
public long Ts { get; set; }
|
public long Ts { get; set; }
|
||||||
|
public bool SenderPro { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ConversationDto
|
public class ConversationDto
|
||||||
|
|||||||
@@ -265,7 +265,9 @@ public class SocialService
|
|||||||
.OrderBy(m => m.CreatedAt).ToListAsync();
|
.OrderBy(m => m.CreatedAt).ToListAsync();
|
||||||
var unread = msgs.Where(m => m.UserId == peerId && m.PeerId == uid && !m.ReadByPeer).ToList();
|
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(); }
|
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<ChatMessageDto> Send(string uid, string peerId, string text)
|
public async Task<ChatMessageDto> Send(string uid, string peerId, string text)
|
||||||
@@ -274,14 +276,24 @@ public class SocialService
|
|||||||
_db.Messages.Add(m);
|
_db.Messages.Add(m);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
await _hub.Clients.User(peerId).SendAsync("chat", new { peerId = uid });
|
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()
|
/// <summary>True when the user has an active premium (pro) plan.</summary>
|
||||||
|
private async Task<bool> IsPro(string userId)
|
||||||
|
{
|
||||||
|
var row = await _db.Profiles.FindAsync(userId);
|
||||||
|
if (row == null) return false;
|
||||||
|
var p = JsonSerializer.Deserialize<ProfileDto>(row.Json, JsonOpts.Default);
|
||||||
|
return p?.Plan == "pro";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatMessageDto ToDto(MessageRow m, string uid, bool senderPro = false) => new()
|
||||||
{
|
{
|
||||||
Id = m.Id.ToString(),
|
Id = m.Id.ToString(),
|
||||||
FromMe = m.UserId == uid,
|
FromMe = m.UserId == uid,
|
||||||
Text = m.Text,
|
Text = m.Text,
|
||||||
Ts = new DateTimeOffset(m.CreatedAt).ToUnixTimeMilliseconds(),
|
Ts = new DateTimeOffset(m.CreatedAt).ToUnixTimeMilliseconds(),
|
||||||
|
SenderPro = senderPro,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ function Card() {
|
|||||||
const dismiss = useCelebrationStore((s) => s.dismiss);
|
const dismiss = useCelebrationStore((s) => s.dismiss);
|
||||||
const leveled = (current.levelAfter ?? 0) > (current.levelBefore ?? 0);
|
const leveled = (current.levelAfter ?? 0) > (current.levelBefore ?? 0);
|
||||||
const xp = useCountUp(current.xpGained ?? 0, 900, current.variant === "xp");
|
const xp = useCountUp(current.xpGained ?? 0, 900, current.variant === "xp");
|
||||||
|
const coins = useCountUp(current.coins ?? 0, 1000, (current.coins ?? 0) > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -82,13 +83,26 @@ function Card() {
|
|||||||
transition={{ type: "spring", stiffness: 180, delay: 0.1 }}
|
transition={{ type: "spring", stiffness: 180, delay: 0.1 }}
|
||||||
className="relative text-6xl mb-1"
|
className="relative text-6xl mb-1"
|
||||||
>
|
>
|
||||||
{current.icon ?? (current.variant === "xp" ? "⚡" : "🛍️")}
|
{current.icon ?? (current.variant === "xp" ? "⚡" : current.variant === "daily" ? "🎁" : "🛍️")}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{current.title && (
|
{current.title && (
|
||||||
<h2 className="relative gold-text text-xl font-black mt-1">{current.title}</h2>
|
<h2 className="relative gold-text text-xl font-black mt-1">{current.title}</h2>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* coins gained (daily reward, etc.) */}
|
||||||
|
{(current.coins ?? 0) > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.6, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 200, delay: 0.15 }}
|
||||||
|
className="relative mt-3 inline-flex items-center gap-2 btn-gold rounded-full px-5 py-2 font-black text-lg"
|
||||||
|
>
|
||||||
|
<Coins className="size-5" />
|
||||||
|
+{coins.toLocaleString()}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* XP gain */}
|
{/* XP gain */}
|
||||||
{current.variant === "xp" && (current.xpGained ?? 0) > 0 && (
|
{current.variant === "xp" && (current.xpGained ?? 0) > 0 && (
|
||||||
<div className="relative mt-3">
|
<div className="relative mt-3">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useUIStore } from "@/lib/ui-store";
|
|||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { DAILY_REWARDS } from "@/lib/online/gamification";
|
import { DAILY_REWARDS } from "@/lib/online/gamification";
|
||||||
import { getService } from "@/lib/online/service";
|
import { getService } from "@/lib/online/service";
|
||||||
|
import { celebrate } from "@/lib/celebration-store";
|
||||||
import { sound } from "@/lib/sound";
|
import { sound } from "@/lib/sound";
|
||||||
import { DailyRewardState } from "@/lib/online/types";
|
import { DailyRewardState } from "@/lib/online/types";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
@@ -48,7 +49,9 @@ export function DailyRewardModal() {
|
|||||||
setClaimed(res.reward);
|
setClaimed(res.reward);
|
||||||
sound.play("award");
|
sound.play("award");
|
||||||
await refreshProfile();
|
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;
|
const isMegaDay = state?.day === 7;
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function ChatScreen() {
|
|||||||
"max-w-[78%] rounded-2xl px-3.5 py-2 text-sm",
|
"max-w-[78%] rounded-2xl px-3.5 py-2 text-sm",
|
||||||
m.fromMe
|
m.fromMe
|
||||||
? cn("ms-auto rounded-ee-sm", isPro ? "premium-chat" : "btn-gold")
|
? 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}
|
{m.text}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import { AchievementUnlock } from "./online/types";
|
|||||||
/** A queued celebration shown by <CelebrationOverlay/>. */
|
/** A queued celebration shown by <CelebrationOverlay/>. */
|
||||||
export interface Celebration {
|
export interface Celebration {
|
||||||
id: number;
|
id: number;
|
||||||
variant: "xp" | "purchase";
|
variant: "xp" | "purchase" | "daily";
|
||||||
title?: string;
|
title?: string;
|
||||||
/** emoji/glyph shown big at the top */
|
/** emoji/glyph shown big at the top */
|
||||||
icon?: string;
|
icon?: string;
|
||||||
xpGained?: number;
|
xpGained?: number;
|
||||||
|
/** coins gained (daily reward, etc.) — shown with a count-up */
|
||||||
|
coins?: number;
|
||||||
levelBefore?: number;
|
levelBefore?: number;
|
||||||
levelAfter?: number;
|
levelAfter?: number;
|
||||||
achievements?: AchievementUnlock[];
|
achievements?: AchievementUnlock[];
|
||||||
|
|||||||
@@ -616,11 +616,14 @@ export class MockOnlineService implements OnlineService {
|
|||||||
fromMe: true,
|
fromMe: true,
|
||||||
text: text.trim(),
|
text: text.trim(),
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
|
senderPro: this.profile?.plan === "pro",
|
||||||
};
|
};
|
||||||
this.messages[friendId] = [...(this.messages[friendId] ?? []), msg];
|
this.messages[friendId] = [...(this.messages[friendId] ?? []), msg];
|
||||||
this.saveChats();
|
this.saveChats();
|
||||||
this.emitChat(friendId);
|
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
|
// simulate a reply from the friend
|
||||||
this.after(randInt(900, 1900), () => {
|
this.after(randInt(900, 1900), () => {
|
||||||
const reply: ChatMessage = {
|
const reply: ChatMessage = {
|
||||||
@@ -628,6 +631,7 @@ export class MockOnlineService implements OnlineService {
|
|||||||
fromMe: false,
|
fromMe: false,
|
||||||
text: pick(CANNED_REPLIES),
|
text: pick(CANNED_REPLIES),
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
|
senderPro: friendPro,
|
||||||
};
|
};
|
||||||
this.messages[friendId] = [...(this.messages[friendId] ?? []), reply];
|
this.messages[friendId] = [...(this.messages[friendId] ?? []), reply];
|
||||||
this.unread[friendId] = (this.unread[friendId] ?? 0) + 1;
|
this.unread[friendId] = (this.unread[friendId] ?? 0) + 1;
|
||||||
|
|||||||
@@ -497,6 +497,8 @@ export interface ChatMessage {
|
|||||||
fromMe: boolean;
|
fromMe: boolean;
|
||||||
text: string;
|
text: string;
|
||||||
ts: number;
|
ts: number;
|
||||||
|
/** the sender has a premium (pro) plan → show gold/animated bubble */
|
||||||
|
senderPro?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
|
|||||||
Reference in New Issue
Block a user