"use client"; /** * /reward-gifts — hand a sticker to everyone, outside the streak. * * These arrive wrapped: each devotee sees a blurred tile in their rewards * gallery, and opening it plays the same celebration a streak reward does. * Nothing is required of them to earn it. * * Sending is idempotent — a repeat reaches only accounts that don't already * hold the sticker — so a double click tops up rather than duplicating. */ import { useEffect, useState } from "react"; import { Can } from "@/components/staff-context"; import Image from "next/image"; import { toast } from "sonner"; import { rewardGifts, type GiftableReward } from "@/lib/api"; import { compressImage } from "@/lib/compress-image"; import { Clamp } from "@/components/ui/clamp"; export default function RewardGiftsPage() { const [catalog, setCatalog] = useState(null); const [sending, setSending] = useState(null); useEffect(() => { rewardGifts .catalog() .then(setCatalog) .catch(() => { toast.error("Could not load the reward catalog"); setCatalog([]); }); }, []); const send = async (reward: GiftableReward) => { if ( !window.confirm( `Send “${reward.name}” to every devotee? They'll see it wrapped in their gallery.`, ) ) { return; } setSending(reward.key); try { const { granted } = await rewardGifts.send(reward.key); toast.success( granted === 0 ? "Everyone already has this one" : `Sent to ${granted} ${granted === 1 ? "devotee" : "devotees"}`, ); } catch (err) { toast.error( err instanceof Error ? err.message : "Could not send the gift", ); } finally { setSending(null); } }; const remove = async (reward: GiftableReward) => { if ( !window.confirm( `Delete “${reward.name}”? It disappears from the gallery of everyone who has it. Stickers already sent in chats stay put.`, ) ) { return; } setSending(reward.key); try { await rewardGifts.remove(reward.key); setCatalog((prev) => prev?.filter((c) => c.key !== reward.key) ?? null); toast.success("Deleted"); } catch (err) { toast.error(err instanceof Error ? err.message : "Could not delete"); } finally { setSending(null); } }; return (

Occasional gifts

Send a sticker to everyone, no streak required. It arrives wrapped and opens with a celebration.

setCatalog((prev) => (prev ? [...prev, reward] : [reward])) } /> {catalog === null ? (

Loading…

) : catalog.length === 0 ? (

The reward catalog is empty.

) : (
    {catalog.map((r) => (
  • {r.name}
    {r.name}
    {/* Seeded rewards have no delete: they would return on the next seed run, having been taken from the galleries of everyone who earned them. */} {r.key.startsWith("custom_") && ( )}
  • ))}
)}
); } /** * Upload artwork and add it to the catalog. * * Creating and sending are deliberately separate steps: a new sticker lands * in the grid below, where it is sent with the same confirmation as any * other — so a mistyped name is caught before it reaches everyone. */ function CustomGift({ onCreated, }: { onCreated: (reward: GiftableReward) => void; }) { const [name, setName] = useState(""); const [meaning, setMeaning] = useState(""); const [imageUrl, setImageUrl] = useState(null); const [uploading, setUploading] = useState(false); const [saving, setSaving] = useState(false); const upload = async (original: File) => { setUploading(true); try { // WebP, NOT the helper's default JPEG: JPEG has no alpha channel, and // compress-image paints a white backdrop behind it so transparency // doesn't turn black. A sticker has to keep its cut-out, so it encodes // to a format that has alpha — the white fill is skipped for anything // that isn't JPEG. // // 1024px, not the gallery tile's 120px: the reward detail renders the // artwork at 224 CSS px (rewards-gallery.tsx:184), which is 672 real // pixels on a 3× screen — so anything smaller is upscaled and visibly // soft exactly where the artwork is largest. Quality is raised too: // stickers are flat illustration with hard edges, where WebP's default // ringing shows far more than it does on a photograph. const file = await compressImage(original, { maxDimension: 1024, quality: 0.95, mimeType: "image/webp", }).catch(() => original); const { upload_url, image_url } = await rewardGifts.presignImage( file.type, ); const res = await fetch(upload_url, { method: "PUT", headers: { "Content-Type": file.type }, body: file, }); if (!res.ok) throw new Error(String(res.status)); setImageUrl(image_url); toast.success("Artwork uploaded"); } catch { toast.error("Could not upload that image"); } finally { setUploading(false); } }; const save = async () => { if (!name.trim() || !imageUrl) return; setSaving(true); try { const reward = await rewardGifts.create({ name: name.trim(), meaning: meaning.trim(), image_url: imageUrl, }); onCreated(reward); toast.success("Added to the catalog — send it when you're ready"); setName(""); setMeaning(""); setImageUrl(null); } catch (err) { toast.error(err instanceof Error ? err.message : "Could not save"); } finally { setSaving(false); } }; const field = "w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-primary/40"; return (

Add a custom gift

It joins the catalog below. Nobody receives it until you send it.

setName(e.target.value)} maxLength={80} placeholder="Name — e.g. Tulasī Leaf" className={field} /> setMeaning(e.target.value)} maxLength={200} placeholder="Meaning (optional)" className={field} />
{ const f = e.target.files?.[0]; if (f) void upload(f); e.target.value = ""; }} className={`${field} sm:w-auto`} /> {imageUrl && ( )}
); }