cpns / apps /web /src /routes /admin.featured.tsx
pramudya-yb
Rename project from Labas to Pram
2b62b0c
Raw
History Blame Contribute Delete
10.7 kB
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { trpc } from "@/utils/trpc";
import { Input } from "@pram/ui/components/input";
import { Button } from "@pram/ui/components/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@pram/ui/components/tabs";
import { toast } from "sonner";
import { getErrorMessage } from "@/lib/error-utils";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { MaterialIcon } from "@/components/ui/MaterialIcon";
export const Route = createFileRoute("/admin/featured")({
component: AdminFeatured,
});
type Tab = "featured" | "packages" | "questions";
function AdminFeatured() {
const [tab, setTab] = useState<Tab>("featured");
const [search, debouncedSearch, setSearch] = useDebouncedValue("", 300);
const [page, setPage] = useState(1);
const limit = 15;
const queryClient = useQueryClient();
const featured = useQuery(trpc.admin.listFeatured.queryOptions());
const fPackages = featured.data?.packages ?? [];
const fQuestions = featured.data?.questions ?? [];
const searchQuery = useQuery(
trpc.admin.searchContent.queryOptions(
{ search: debouncedSearch, type: tab === "packages" ? "packages" : "questions", limit, offset: (page - 1) * limit },
{ enabled: tab !== "featured" },
),
);
const togglePkgMutation = useMutation(
trpc.admin.toggleFeaturedPackage.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: trpc.admin.listFeatured.queryKey() });
queryClient.invalidateQueries({ queryKey: trpc.admin.searchContent.queryKey() });
toast.success("Package updated");
},
onError: (e: unknown) => toast.error(getErrorMessage(e)),
}),
);
const toggleQMutation = useMutation(
trpc.admin.toggleFeaturedQuestion.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: trpc.admin.listFeatured.queryKey() });
queryClient.invalidateQueries({ queryKey: trpc.admin.searchContent.queryKey() });
toast.success("Question updated");
},
onError: (e: unknown) => toast.error(getErrorMessage(e)),
}),
);
function handleSearch(val: string) {
setSearch(val);
setPage(1);
}
const tabs: { key: Tab; label: string }[] = [
{ key: "featured", label: "Currently Featured" },
{ key: "packages", label: "Browse Packages" },
{ key: "questions", label: "Browse Questions" },
];
return (
<div>
<h1 className="text-3xl font-headline font-bold text-[var(--clay-black)] mb-2">Editor&apos;s Pick</h1>
<p className="text-[var(--warm-charcoal)] mb-6">Curate featured content for the homepage.</p>
<Tabs
value={tab}
onValueChange={(v) => { setTab(v as Tab); setSearch(""); setPage(1); }}
className="mb-6"
>
<TabsList variant="line" className="h-10 bg-transparent border-b border-[var(--oat-border)]">
{tabs.map((t) => (
<TabsTrigger
key={t.key}
value={t.key}
className="px-4 py-2.5 text-sm font-medium rounded-t-[var(--radius-lg)] data-[active=true]:bg-[var(--pure-white)] data-[active=true]:text-[var(--clay-black)] data-[active=true]:border data-[active=true]:border-[var(--oat-border)] data-[active=true]:border-b-[var(--pure-white)] data-[active=true]:-mb-[1px] text-[var(--warm-charcoal)] hover:text-[var(--clay-black)] transition-colors"
>
{t.label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="featured" className="mt-6">
<div className="space-y-8">
<section>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-headline font-bold text-[var(--clay-black)]">Featured Packages</h2>
<span className="text-sm text-[var(--warm-charcoal)]">({fPackages.length})</span>
</div>
{fPackages.length === 0 ? (
<p className="text-sm text-[var(--warm-charcoal)] py-4">No featured packages. Browse and select from the tabs above.</p>
) : (
<div className="space-y-2">
{fPackages.map((pkg) => (
<div key={pkg.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
<div>
<p className="font-medium text-[var(--clay-black)]">{pkg.title}</p>
<p className="text-xs text-[var(--warm-charcoal)]">{pkg.examTypeId}</p>
</div>
<Button variant="outline" onClick={() => togglePkgMutation.mutate({ packageId: pkg.id })} disabled={togglePkgMutation.isPending} className="h-9 rounded-[var(--radius-lg)] text-xs">Unfeature</Button>
</div>
))}
</div>
)}
</section>
<section>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-headline font-bold text-[var(--clay-black)]">Featured Questions</h2>
<span className="text-sm text-[var(--warm-charcoal)]">({fQuestions.length})</span>
</div>
{fQuestions.length === 0 ? (
<p className="text-sm text-[var(--warm-charcoal)] py-4">No featured questions.</p>
) : (
<div className="space-y-2">
{fQuestions.map((q) => (
<div key={q.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
<div className="flex-1 min-w-0">
<p className="font-medium text-[var(--clay-black)] truncate">{q.questionText}</p>
<p className="text-xs text-[var(--warm-charcoal)]">{q.format} · {q.examTypeId}</p>
</div>
<Button variant="outline" className="shrink-0 ml-4 h-9 rounded-[var(--radius-lg)] text-xs" onClick={() => toggleQMutation.mutate({ questionId: q.id })} disabled={toggleQMutation.isPending}>Unfeature</Button>
</div>
))}
</div>
)}
</section>
</div>
</TabsContent>
<TabsContent value="packages" className="mt-6">
<div className="relative flex-1">
<MaterialIcon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--warm-charcoal)]" />
<Input
placeholder="Search packages..."
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Search packages"
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
/>
</div>
<SearchResults query={searchQuery} tab="packages" page={page} setPage={setPage} limit={limit} togglePkgMutation={togglePkgMutation} toggleQMutation={toggleQMutation} />
</TabsContent>
<TabsContent value="questions" className="mt-6">
<Input
placeholder="Search questions..."
value={search}
onChange={(e) => handleSearch(e.target.value)}
className="max-w-md mb-4"
/>
<SearchResults query={searchQuery} tab="questions" page={page} setPage={setPage} limit={limit} togglePkgMutation={togglePkgMutation} toggleQMutation={toggleQMutation} />
</TabsContent>
</Tabs>
</div>
);
}
function SearchResults({
query,
tab,
page,
setPage,
limit,
togglePkgMutation,
toggleQMutation,
}: {
query: ReturnType<typeof useQuery>;
tab: "packages" | "questions";
page: number;
setPage: (fn: (p: number) => number) => void;
limit: number;
togglePkgMutation: { mutate: (args: { packageId: string }) => void; isPending: boolean };
toggleQMutation: { mutate: (args: { questionId: string }) => void; isPending: boolean };
}) {
if (query.isLoading) {
return <p className="text-sm text-[var(--warm-charcoal)] py-8 text-center">Searching...</p>;
}
const data = query.data as { items: Array<{ id: string; title?: string; questionText?: string; format?: string; examTypeId?: string; isFeatured?: boolean }>; total: number } | undefined;
return (
<>
<p className="text-sm text-[var(--warm-charcoal)] mb-3">
{query.isSuccess ? `Showing ${data?.total ?? 0} ${tab}` : ""}
</p>
<div className="space-y-2">
{(data?.items ?? []).map((item) => (
<div key={item.id} className="flex items-center justify-between bg-[var(--pure-white)] border border-[var(--oat-border)] rounded-[var(--radius-lg)] px-4 py-3">
<div className="flex-1 min-w-0">
<p className="font-medium text-[var(--clay-black)] truncate">
{item.title ?? item.questionText}
</p>
<p className="text-xs text-[var(--warm-charcoal)]">
{item.format ? `${item.format} · ${item.examTypeId}` : item.examTypeId}
{item.isFeatured && (
<span className="ml-2 text-xs font-semibold px-1.5 py-0.5 rounded-full bg-[var(--sunbeam-300)] text-[var(--sunbeam-800)]">Featured</span>
)}
</p>
</div>
<Button
variant={item.isFeatured ? "outline" : "default"}
className="shrink-0 ml-4 h-9 rounded-[var(--radius-lg)] text-xs"
onClick={() => {
if (tab === "packages") togglePkgMutation.mutate({ packageId: item.id });
else toggleQMutation.mutate({ questionId: item.id });
}}
disabled={togglePkgMutation.isPending || toggleQMutation.isPending}
>
{item.isFeatured ? "Unfeature" : "Feature"}
</Button>
</div>
))}
</div>
{data && data.total > limit && (
<div className="flex items-center justify-center gap-4 mt-6">
<Button variant="outline" size="sm" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>Previous</Button>
<span className="text-sm text-[var(--warm-charcoal)]">Page {page} of {Math.ceil(data.total / limit)}</span>
<Button variant="outline" size="sm" onClick={() => setPage((p) => p + 1)} disabled={page * limit >= data.total}>Next</Button>
</div>
)}
</>
);
}