feat: chat stickers

This commit is contained in:
gosangam
2026-08-30 02:19:31 +05:30
parent 8c7afe596d
commit 2597c6f2ec
9 changed files with 332 additions and 18 deletions
+63 -8
View File
@@ -15,9 +15,11 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import Image from "next/image";
import { Gift } from "lucide-react";
import type { SadhnaRewardGrant } from "@/lib/sadhna";
import { Gift, Sparkles } from "lucide-react";
import { sadhna, type SadhnaRewardGrant } from "@/lib/sadhna";
import { useMounted } from "@/lib/use-mounted";
import { RewardCelebration } from "@/components/sadhna/reward-celebration";
import { cn } from "@/lib/utils";
export function RewardsGallery({
collected,
@@ -27,6 +29,11 @@ export function RewardsGallery({
nextRewardDate: string | null;
}) {
const [detail, setDetail] = useState<SadhnaRewardGrant | null>(null);
// The gift currently being opened — shown enlarged, over confetti.
const [unwrapping, setUnwrapping] = useState<SadhnaRewardGrant | null>(null);
// Gifts opened in this session. Held locally so the tile clears the moment
// the celebration is dismissed, rather than waiting for a page refresh.
const [justOpened, setJustOpened] = useState<Set<string>>(new Set());
// De-dupe by reward (collected is newest-first, so the first wins).
const seen = new Set<string>();
@@ -38,6 +45,19 @@ export function RewardsGallery({
}
}
// A gift stays wrapped until opened. Everything else — every streak
// reward — is simply collected.
const isWrapped = (g: SadhnaRewardGrant) =>
g.source === "gift" && !g.opened && !justOpened.has(g.reward.key);
const openGift = (g: SadhnaRewardGrant) => {
setUnwrapping(null);
setJustOpened((prev) => new Set(prev).add(g.reward.key));
// The tile has already cleared; a failed write only means it arrives
// wrapped again next load, which is better than blocking the celebration.
void sadhna.openGift(g.reward.key).catch(() => {});
};
return (
<section className="rounded-2xl border border-border bg-card p-4 sm:p-5">
<div className="mb-3 flex items-center gap-2">
@@ -61,20 +81,42 @@ export function RewardsGallery({
<button
key={g.reward.key}
type="button"
onClick={() => setDetail(g)}
className="group flex flex-col items-center gap-1.5 rounded-xl border border-border bg-background p-2 text-center transition-colors hover:border-primary/40 hover:bg-muted/40"
onClick={() =>
isWrapped(g) ? setUnwrapping(g) : setDetail(g)
}
aria-label={
isWrapped(g)
? "A gift is waiting — tap to open"
: g.reward.name
}
className={cn(
"group flex flex-col items-center gap-1.5 rounded-xl border bg-background p-2 text-center transition-colors",
isWrapped(g)
? "border-primary/40 hover:border-primary"
: "border-border hover:border-primary/40 hover:bg-muted/40",
)}
>
<div className="relative aspect-square w-full">
<Image
src={g.reward.image_url}
alt={g.reward.name}
alt={isWrapped(g) ? "" : g.reward.name}
fill
sizes="(max-width: 640px) 30vw, 120px"
className="object-contain p-1"
className={cn(
"object-contain p-1 transition-all",
// Blurred, not hidden: the shape hints at what's coming
// without giving it away.
isWrapped(g) && "scale-95 blur-md",
)}
/>
{isWrapped(g) && (
<span className="absolute inset-0 flex items-center justify-center">
<Sparkles className="size-6 text-primary drop-shadow" />
</span>
)}
</div>
<span className="line-clamp-2 text-[13px] font-medium leading-tight text-foreground">
{shortName(g.reward.name)}
{isWrapped(g) ? "A gift for you" : shortName(g.reward.name)}
</span>
</button>
))}
@@ -93,6 +135,15 @@ export function RewardsGallery({
{detail ? (
<RewardDetail grant={detail} onClose={() => setDetail(null)} />
) : null}
{/* Reuses the streak celebration wholesale — a gift should feel like
any other sacred reward arriving, not like a second mechanism. */}
{unwrapping ? (
<RewardCelebration
grant={unwrapping}
onClose={() => openGift(unwrapping)}
/>
) : null}
</section>
);
}
@@ -145,7 +196,11 @@ function RewardDetail({
</p>
) : null}
<p className="mt-4 text-xs text-muted-foreground">
Earned on day {grant.milestone} of your streak
{/* A gift has no milestone — saying "day null of your streak"
would be worse than saying nothing about where it came from. */}
{grant.milestone !== null
? `Earned on day ${grant.milestone} of your streak`
: "An occasional gift"}
</p>
<button
type="button"