Files
spsays-admin/app/(dashboard)/reward-gifts/page.tsx
T
2026-10-16 15:45:29 +05:30

311 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<GiftableReward[] | null>(null);
const [sending, setSending] = useState<string | null>(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 (
<div className="space-y-6">
<header>
<h1 className="text-xl font-semibold text-foreground">
Occasional gifts
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Send a sticker to everyone, no streak required. It arrives wrapped and
opens with a celebration.
</p>
</header>
<Can perm="rewards:write">
<CustomGift
onCreated={(reward) =>
setCatalog((prev) => (prev ? [...prev, reward] : [reward]))
}
/>
</Can>
{catalog === null ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : catalog.length === 0 ? (
<p className="text-sm text-muted-foreground">
The reward catalog is empty.
</p>
) : (
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{catalog.map((r) => (
<li
key={r.key}
// h-full + mt-auto on the button: grid rows stretch to their
// tallest tile, but without this the contents stay top-packed
// and a two-line name pushes its button below the others.
className="flex h-full flex-col items-center gap-2 rounded-xl border border-border p-3 text-center"
>
<div className="relative aspect-square w-full">
<Image
src={r.image_url}
alt={r.name}
fill
sizes="160px"
className="object-contain p-1"
unoptimized
/>
</div>
<Clamp lines={2} className="w-full text-sm font-medium text-foreground">
{r.name}
</Clamp>
<div className="mt-auto w-full space-y-1.5">
<Can perm="rewards:send">
<button
type="button"
disabled={sending !== null}
onClick={() => send(r)}
className="w-full rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{sending === r.key ? "Sending…" : "Send to everyone"}
</button>
</Can>
{/* Seeded rewards have no delete: they would return on the
next seed run, having been taken from the galleries of
everyone who earned them. */}
<Can perm="rewards:delete">
{r.key.startsWith("custom_") && (
<button
type="button"
disabled={sending !== null}
onClick={() => remove(r)}
className="w-full rounded-md border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
>
Delete
</button>
)}
</Can>
</div>
</li>
))}
</ul>
)}
</div>
);
}
/**
* 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<string | null>(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 (
<section className="space-y-3 rounded-xl border border-border p-4">
<div>
<h2 className="text-sm font-medium text-foreground">
Add a custom gift
</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
It joins the catalog below. Nobody receives it until you send it.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<input
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={80}
placeholder="Name — e.g. Tulasī Leaf"
className={field}
/>
<input
value={meaning}
onChange={(e) => setMeaning(e.target.value)}
maxLength={200}
placeholder="Meaning (optional)"
className={field}
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<input
type="file"
// No JPEG: it cannot carry transparency, so it can only produce a
// sticker with a hard rectangular edge.
accept="image/png,image/webp"
disabled={uploading}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void upload(f);
e.target.value = "";
}}
className={`${field} sm:w-auto`}
/>
{imageUrl && (
<span className="relative size-12 shrink-0">
<Image
src={imageUrl}
alt=""
fill
sizes="48px"
className="object-contain"
unoptimized
/>
</span>
)}
<button
type="button"
disabled={saving || uploading || !name.trim() || !imageUrl}
onClick={save}
className="ml-auto rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{saving ? "Adding…" : "Add to catalog"}
</button>
</div>
</section>
);
}