feat: chat stickers
This commit is contained in:
@@ -64,7 +64,9 @@ export function RewardCelebration({
|
||||
You've earned a reward!
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
For {grant.milestone} days of unbroken sādhana 🙏
|
||||
{grant.milestone !== null
|
||||
? `For ${grant.milestone} days of unbroken sādhana 🙏`
|
||||
: "A gift for you 🙏"}
|
||||
</p>
|
||||
|
||||
<div className="relative mx-auto my-6 flex size-48 items-center justify-center">
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user