feat(don): add public top donation page
Build and Push Image / build-backend (push) Successful in 18s
Build and Push Image / push (push) Has been cancelled
Build and Push Image / build-frontend (push) Has been cancelled

This commit is contained in:
2026-06-09 13:12:59 +07:00 Unverified
parent f53b900e37
commit 15dd88dde9
3 changed files with 197 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
import { asc, desc, sql } from "drizzle-orm";
import { Suspense } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { db } from "@/lib/db";
import { donations } from "@/lib/db/schema";
import { fileToDataUrl } from "@/lib/utils";
import { Podium } from "./podium";
import { TopDonateTable } from "./table";
type NumberString = `${number}`;
type NumberLike = number | NumberString;
export default function Page() {
return (
<Suspense fallback={<PageSkeleton />}>
<Content />
</Suspense>
);
}
function PageSkeleton() {
return (
<div className="min-h-svh flex flex-col justify-center">
<div className="max-w-4xl w-full mx-auto flex flex-col gap-4 p-4">
<div className="flex justify-center items-end gap-4 mt-6">
<Skeleton className="h-36 w-36 rounded-xl" />
<Skeleton className="h-48 w-36 rounded-xl" />
<Skeleton className="h-32 w-36 rounded-xl" />
</div>
<Skeleton className="h-64 w-full rounded-xl" />
</div>
</div>
);
}
async function Content() {
const list = await db
.select({
i: sql<NumberLike>`ROW_NUMBER() OVER
(ORDER BY ${desc(donations.amount)}, ${asc(donations.id)})`,
name: donations.name,
amount: donations.amount,
image: donations.image,
})
.from(donations)
.offset(3)
.limit(7)
.orderBy(desc(donations.amount), asc(donations.id));
const podium = await db
.select({
name: donations.name,
amount: donations.amount,
image: donations.image,
})
.from(donations)
.limit(3)
.orderBy(desc(donations.amount), asc(donations.id))
.then((r) =>
Promise.all(
r.map(async (d) => ({
...d,
image: d.image
? await fileToDataUrl(new File([Buffer.from(d.image)], "image.jpg"))
: undefined,
})),
),
);
return (
<div className="min-h-svh flex flex-col justify-center">
<div className="max-w-4xl mx-auto flex flex-col gap-4 p-4">
<Podium data={podium} />
<TopDonateTable data={list} />
</div>
</div>
);
}
export const dynamic = "force-dynamic";
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { Award, Trophy } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Card, CardContent } from "@/components/ui/card";
const styles = [
{
border: "border-yellow-500/50",
bg: "bg-yellow-500/5",
height: "h-48",
text: "text-yellow-600",
},
{
border: "border-zinc-400/50",
bg: "bg-zinc-400/5",
height: "h-36",
text: "text-zinc-500",
},
{
border: "border-amber-700/50",
bg: "bg-amber-700/5",
height: "h-32",
text: "text-amber-700",
},
];
interface TopDonateRow {
name: string;
amount: number;
image?: string;
}
export function Podium({ data }: { data: TopDonateRow[] }) {
// [1,2,3] -> [2,1,3]
const order = [data[1], data[0], data[2]].filter(Boolean);
return (
<div className="flex justify-center items-end gap-4 mt-6">
{order.map((donor, index) => {
const rank = index === 0 ? 1 : index === 1 ? 0 : 2;
const s = styles[rank];
const initial = donor.name?.charAt(0).toUpperCase() ?? "?";
return (
<div key={rank} className="relative flex flex-col items-center">
<div className="absolute -top-8 z-10">
{rank === 0 ? (
<Trophy size={28} className="text-yellow-500" />
) : (
<Award size={28} className={s.text} />
)}
</div>
<Card
className={`${s.border} ${s.bg} ${s.height} w-36 flex flex-col items-center justify-end pb-0 backdrop-blur-md`}
>
<CardContent className="flex flex-col items-center gap-2 p-4">
<Avatar className="size-10">
<AvatarImage src={donor.image} />
<AvatarFallback>{initial}</AvatarFallback>
</Avatar>
<span className="font-semibold text-sm truncate w-full text-center">
{donor.name}
</span>
<span className={`text-lg font-bold ${s.text}`}>
{donor.amount}฿
</span>
</CardContent>
</Card>
</div>
);
})}
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import type { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@/components/tantable";
type NumberString = `${number}`;
type NumberLike = number | NumberString;
interface TopDonateRow {
i: NumberLike;
name: string;
amount: number;
}
const columns: ColumnDef<TopDonateRow>[] = [
{
accessorKey: "i",
header: "#",
meta: { className: "w-12 text-center" },
},
{
accessorKey: "name",
header: "Name",
meta: { className: "w-full truncate" },
},
{
accessorFn: (row) => `${row.amount}฿`,
header: "Amount",
meta: { className: "w-24" },
},
];
export function TopDonateTable({ data }: { data: TopDonateRow[] }) {
return (
<DataTable
columns={columns}
data={data}
emptyDescription="No donations yet."
className="w-full h-full max-h-[calc(100svh-264px)] overflow-y-auto backdrop-blur-md"
/>
);
}