email templates and subs system

This commit is contained in:
brooktewabe 2026-06-06 15:07:58 +03:00
parent a15064ce37
commit 97335f2650
15 changed files with 1060 additions and 26 deletions

View File

@ -35,6 +35,10 @@ import SystemMembersPage from "@/pages/admin/system-members";
import IssuesPage from "@/pages/admin/issues";
import FaqSupportPage from "@/pages/admin/support/faq";
import NotificationBroadcastPage from "@/pages/admin/notifications/broadcast";
import EmailTemplatesPage from "@/pages/admin/email-templates";
import EmailTemplatePreviewPage from "@/pages/admin/email-templates/[key]";
import SubscriptionsAdminPage from "@/pages/admin/subscriptions";
import PlanManagementPage from "@/pages/admin/subscriptions/plans/[id]";
function App() {
return (
@ -110,6 +114,17 @@ function App() {
element={<ProformaRequestsPage />}
/>
<Route path="admin/health" element={<HealthPage />} />
<Route path="admin/email-templates" element={<EmailTemplatesPage />} />
<Route
path="admin/email-templates/:key"
element={<EmailTemplatePreviewPage />}
/>
<Route path="admin/subscriptions" element={<SubscriptionsAdminPage />} />
<Route
path="admin/subscriptions/plans/:id"
element={<PlanManagementPage />}
/>
<Route path="notifications" element={<NotificationsPage />} />
</Route>
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />

View File

@ -26,6 +26,8 @@ import {
LifeBuoy,
HelpCircle,
Send,
Mail,
Layers,
} from "lucide-react"
export interface AdminSearchRoute {
@ -103,9 +105,27 @@ export const ADMIN_SEARCH_ROUTES: AdminSearchRoute[] = [
title: "Subscription transactions",
description:
"Successful and failed subscription charges for the platform.",
group: "Commerce",
group: "Subscriptions",
icon: ArrowRightLeft,
},
{
id: "subscription-plans",
path: "/admin/subscriptions",
title: "Subscription plans",
description:
"Manage plan pricing, feature flags, limits, and activation.",
group: "Subscriptions",
icon: Layers,
},
{
id: "email-templates",
path: "/admin/email-templates",
title: "Email templates",
description:
"Preview transactional email templates with sample data.",
group: "Communications",
icon: Mail,
},
{
id: "users",
path: "/admin/users",
@ -299,6 +319,7 @@ export const ADMIN_SEARCH_ROUTES: AdminSearchRoute[] = [
const GROUP_ORDER = [
"Overview",
"Commerce",
"Subscriptions",
"People & activity",
"Support",
"Operations",

View File

@ -24,6 +24,8 @@ import {
ChevronDown,
ChevronRight,
Folder,
Mail,
Layers,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { AdminQuickSearch } from "@/components/admin-quick-search";
@ -56,6 +58,31 @@ type NavItem = {
visible?: (role: string | undefined) => boolean;
};
function navItemIsActive(
item: NavItem,
isActive: (path?: string) => boolean,
): boolean {
if (item.path && isActive(item.path)) return true;
return item.children?.some((child) => navItemIsActive(child, isActive)) ?? false;
}
function filterNavItems(
items: NavItem[],
role: string | undefined,
): NavItem[] {
return items
.filter((item) => (item.visible ? item.visible(role) : true))
.map((item) =>
item.children
? {
...item,
children: filterNavItems(item.children, role),
}
: item,
)
.filter((item) => !item.children || item.children.length > 0);
}
const adminNavigationItems: NavItem[] = [
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
{
@ -91,8 +118,19 @@ const adminNavigationItems: NavItem[] = [
},
{
icon: ArrowRightLeft,
label: "Subscription transactions",
path: "/admin/transactions/subscriptions",
label: "Subscriptions",
children: [
{
label: "Transactions",
path: "/admin/transactions/subscriptions",
icon: ArrowRightLeft,
},
{
label: "Plans",
path: "/admin/subscriptions",
icon: Layers,
},
],
},
{
icon: Users,
@ -153,9 +191,20 @@ const adminNavigationItems: NavItem[] = [
},
{
icon: Send,
label: "Send notification",
path: "/admin/notifications/broadcast",
visible: (role) => getPermissions(role).canSendNotifications,
label: "Communications",
children: [
{
label: "Send notification",
path: "/admin/notifications/broadcast",
icon: Send,
visible: (role) => getPermissions(role).canSendNotifications,
},
{
label: "Email templates",
path: "/admin/email-templates",
icon: Mail,
},
],
},
];
@ -163,15 +212,18 @@ const SidebarNavItem = ({
item,
depth = 0,
isActive,
role,
}: {
item: NavItem;
depth?: number;
isActive: (path?: string) => boolean;
role: string | undefined;
}) => {
const hasChildren = item.children && item.children.length > 0;
const isCurrentlyActive =
isActive(item.path) ||
(hasChildren && item.children?.some((child) => isActive(child.path)));
const visibleChildren = item.children
? filterNavItems(item.children, role)
: undefined;
const hasChildren = visibleChildren && visibleChildren.length > 0;
const isCurrentlyActive = navItemIsActive(item, isActive);
const [isOpen, setIsOpen] = useState(isCurrentlyActive);
@ -209,12 +261,13 @@ const SidebarNavItem = ({
</button>
{isOpen && (
<div className="space-y-1 ml-4 border-l border-slate-200">
{item.children?.map((child) => (
{visibleChildren?.map((child) => (
<SidebarNavItem
key={child.label}
item={child}
depth={depth + 1}
isActive={isActive}
role={role}
/>
))}
</div>
@ -309,13 +362,12 @@ export function AppShell() {
{/* Navigation */}
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto">
{adminNavigationItems
.filter((item) => (item.visible ? item.visible(user?.role) : true))
.map((item) => (
{filterNavItems(adminNavigationItems, user?.role).map((item) => (
<SidebarNavItem
key={item.label}
item={item}
isActive={isActive}
role={user?.role}
/>
))}
</nav>

View File

@ -0,0 +1,94 @@
import { useParams, useNavigate } from "react-router-dom"
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { ArrowLeft, ExternalLink, Loader2 } from "lucide-react"
import { emailService } from "@/services"
export default function EmailTemplatePreviewPage() {
const { key } = useParams()
const navigate = useNavigate()
const { data: templatesData } = useQuery({
queryKey: ['admin', 'email-templates'],
queryFn: () => emailService.listPreviewTemplates(),
})
const { data: preview, isLoading, error } = useQuery({
queryKey: ['admin', 'email-templates', key, 'preview'],
queryFn: () => emailService.getPreviewJson(key!),
enabled: !!key,
})
const templateMeta = templatesData?.templates.find((t) => t.key === key)
if (isLoading) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Rendering preview...
</div>
)
}
if (error || !preview) {
return (
<div className="space-y-4">
<Button variant="ghost" onClick={() => navigate('/admin/email-templates')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Templates
</Button>
<div className="text-center py-16 text-destructive">
Failed to load email preview.
</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate('/admin/email-templates')}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h2 className="text-3xl font-bold">{templateMeta?.name ?? preview.templateKey}</h2>
<p className="text-muted-foreground mt-1">{templateMeta?.description}</p>
</div>
</div>
<Button variant="outline" asChild>
<a
href={emailService.getPreviewHtmlUrl(key!)}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="w-4 h-4 mr-2" />
Open in New Tab
</a>
</Button>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-2 flex-wrap">
<CardTitle className="text-base">Subject</CardTitle>
<Badge variant="secondary">{preview.subject}</Badge>
<Badge variant="outline" className="font-mono">{preview.templateKey}</Badge>
</div>
</CardHeader>
<CardContent>
<div className="border rounded-lg overflow-hidden bg-white">
<iframe
title={`Email preview: ${preview.templateKey}`}
srcDoc={preview.html}
className="w-full min-h-[700px] border-0"
sandbox="allow-same-origin"
/>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,82 @@
import { useNavigate } from "react-router-dom"
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Mail, Eye, Loader2 } from "lucide-react"
import { emailService } from "@/services"
export default function EmailTemplatesPage() {
const navigate = useNavigate()
const { data, isLoading, error } = useQuery({
queryKey: ['admin', 'email-templates'],
queryFn: () => emailService.listPreviewTemplates(),
})
if (isLoading) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Loading email templates...
</div>
)
}
if (error) {
return (
<div className="text-center py-16 text-destructive">
Failed to load email templates.
</div>
)
}
const templates = data?.templates ?? []
return (
<div className="space-y-6">
<div>
<h2 className="text-3xl font-bold">Email Templates</h2>
<p className="text-muted-foreground mt-1">
Preview React Email templates with sample data and company branding.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{templates.map((template) => (
<Card
key={template.key}
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => navigate(`/admin/email-templates/${template.key}`)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<div className="p-2 rounded-md bg-primary/10">
<Mail className="w-4 h-4 text-primary" />
</div>
<div>
<CardTitle className="text-base">{template.name}</CardTitle>
<Badge variant="secondary" className="mt-1 font-mono text-xs">
{template.key}
</Badge>
</div>
</div>
</div>
<CardDescription className="line-clamp-2">{template.description}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-3">
Subject: {template.defaultSubject}
</p>
<Button variant="outline" size="sm" className="w-full">
<Eye className="w-4 h-4 mr-2" />
Preview Template
</Button>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,105 @@
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Edit, Loader2 } from "lucide-react";
import { subscriptionService } from "@/services";
export default function SubscriptionsAdminPage() {
const navigate = useNavigate();
const { data: plans, isLoading: plansLoading } = useQuery({
queryKey: ["admin", "subscription-plans"],
queryFn: () => subscriptionService.getAdminPlans(),
});
return (
<div className="space-y-6">
<div>
<h2 className="text-3xl font-bold">Subscription Plans</h2>
<p className="text-muted-foreground mt-1">
Manage plan pricing, feature flags, limits, and activation status.
Payment history is under{" "}
<button
type="button"
className="text-primary underline-offset-4 hover:underline"
onClick={() => navigate("/admin/transactions/subscriptions")}
>
Subscriptions Transactions
</button>
.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>All Plans</CardTitle>
</CardHeader>
<CardContent>
{plansLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Loading plans...
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Plan</TableHead>
<TableHead>Monthly (ETB)</TableHead>
<TableHead>Yearly (ETB)</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{plans?.map((plan) => (
<TableRow key={plan.id}>
<TableCell>
<div>
<p className="font-medium">{plan.displayName}</p>
<p className="text-xs text-muted-foreground">{plan.name}</p>
</div>
</TableCell>
<TableCell>
{plan.isFree ? "Free" : plan.monthlyPrice.toLocaleString()}
</TableCell>
<TableCell>
{plan.isFree ? "Free" : plan.yearlyPrice.toLocaleString()}
</TableCell>
<TableCell>
<Badge className={plan.isActive ? "bg-green-500" : "bg-gray-500"}>
{plan.isActive ? "Active" : "Inactive"}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() =>
navigate(`/admin/subscriptions/plans/${plan.id}`)
}
>
<Edit className="w-4 h-4 mr-2" />
Manage
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,250 @@
import { useEffect, useState } from "react"
import { useParams, useNavigate } from "react-router-dom"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ArrowLeft, Loader2 } from "lucide-react"
import { subscriptionService, type PlanFeatures } from "@/services"
import { toast } from "sonner"
import type { ApiError } from "@/types/error.types"
function formatFeatureLabel(key: string) {
return key.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
}
export default function PlanManagementPage() {
const { id } = useParams()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [displayName, setDisplayName] = useState("")
const [description, setDescription] = useState("")
const [monthlyPrice, setMonthlyPrice] = useState("")
const [yearlyPrice, setYearlyPrice] = useState("")
const [isActive, setIsActive] = useState(true)
const [features, setFeatures] = useState<PlanFeatures>({ features: {}, limits: {} })
const { data: plan, isLoading } = useQuery({
queryKey: ['admin', 'subscription-plans', id],
queryFn: () => subscriptionService.getAdminPlan(id!),
enabled: !!id,
})
useEffect(() => {
if (plan) {
setDisplayName(plan.displayName)
setDescription(plan.description ?? "")
setMonthlyPrice(String(plan.monthlyPrice))
setYearlyPrice(String(plan.yearlyPrice))
setIsActive(plan.isActive)
setFeatures(plan.features)
}
}, [plan])
const updatePlanMutation = useMutation({
mutationFn: () =>
subscriptionService.updatePlan(id!, {
displayName,
description,
monthlyPrice: Number(monthlyPrice),
yearlyPrice: Number(yearlyPrice),
isActive,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'subscription-plans'] })
toast.success("Plan settings updated")
},
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to update plan")
},
})
const updateFeaturesMutation = useMutation({
mutationFn: () => subscriptionService.updatePlanFeatures(id!, features),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'subscription-plans'] })
toast.success("Plan features updated")
},
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to update features")
},
})
const toggleFeature = (key: string) => {
setFeatures((prev) => ({
...prev,
features: { ...prev.features, [key]: !prev.features[key] },
}))
}
const updateLimit = (key: string, value: string) => {
const parsed = value.trim() === "" ? null : Number(value)
setFeatures((prev) => ({
...prev,
limits: { ...prev.limits, [key]: parsed },
}))
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Loading plan...
</div>
)
}
if (!plan) {
return <div className="text-center py-16">Plan not found.</div>
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate('/admin/subscriptions')}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h2 className="text-3xl font-bold">{plan.displayName}</h2>
<p className="text-muted-foreground">{plan.name} plan</p>
</div>
</div>
<Tabs defaultValue="pricing">
<TabsList>
<TabsTrigger value="pricing">Pricing & Status</TabsTrigger>
<TabsTrigger value="features">Features & Limits</TabsTrigger>
</TabsList>
<TabsContent value="pricing">
<Card>
<CardHeader>
<CardTitle>Pricing Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4 max-w-lg">
<div className="space-y-2">
<Label htmlFor="displayName">Display Name</Label>
<Input
id="displayName"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{!plan.isFree && (
<>
<div className="space-y-2">
<Label htmlFor="monthlyPrice">Monthly Price (ETB)</Label>
<Input
id="monthlyPrice"
type="number"
min={0}
value={monthlyPrice}
onChange={(e) => setMonthlyPrice(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="yearlyPrice">Yearly Price (ETB)</Label>
<Input
id="yearlyPrice"
type="number"
min={0}
value={yearlyPrice}
onChange={(e) => setYearlyPrice(e.target.value)}
/>
</div>
</>
)}
<div className="flex items-center justify-between rounded-lg border p-4">
<div>
<Label htmlFor="isActive">Plan Active</Label>
<p className="text-xs text-muted-foreground">
Inactive plans are hidden from new subscriptions.
</p>
</div>
<Switch id="isActive" checked={isActive} onCheckedChange={setIsActive} />
</div>
<Button
onClick={() => updatePlanMutation.mutate()}
disabled={updatePlanMutation.isPending}
>
{updatePlanMutation.isPending && (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
)}
Save Pricing
</Button>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="features">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>Feature Flags</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Object.entries(features.features).map(([key, enabled]) => (
<div key={key} className="flex items-center justify-between rounded-lg border p-3">
<Label htmlFor={`feature-${key}`}>{formatFeatureLabel(key)}</Label>
<Switch
id={`feature-${key}`}
checked={enabled}
onCheckedChange={() => toggleFeature(key)}
/>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Usage Limits</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Object.entries(features.limits).map(([key, limit]) => (
<div key={key} className="space-y-1">
<Label htmlFor={`limit-${key}`}>{formatFeatureLabel(key)}</Label>
<Input
id={`limit-${key}`}
type="number"
min={0}
placeholder="Unlimited (empty)"
value={limit === null ? "" : String(limit)}
onChange={(e) => updateLimit(key, e.target.value)}
/>
</div>
))}
</CardContent>
</Card>
</div>
<div className="mt-4">
<Button
onClick={() => updateFeaturesMutation.mutate()}
disabled={updateFeaturesMutation.isPending}
>
{updateFeaturesMutation.isPending && (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
)}
Save Features
</Button>
</div>
</TabsContent>
</Tabs>
</div>
)
}

View File

@ -21,11 +21,11 @@ import type { SubscriptionPaymentStatus } from "@/services/subscription-transact
import { cn } from "@/lib/utils";
export default function SubscriptionTransactionsPage() {
const [tab, setTab] = useState<"succeeded" | "failed">("succeeded");
const [tab, setTab] = useState<"completed" | "failed">("completed");
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const status: SubscriptionPaymentStatus =
tab === "succeeded" ? "SUCCEEDED" : "FAILED";
tab === "completed" ? "COMPLETED" : "FAILED";
const { data, isLoading, error } = useQuery({
queryKey: ["admin", "subscription-transactions", status, page, search],
@ -76,18 +76,18 @@ export default function SubscriptionTransactionsPage() {
<Tabs
value={tab}
onValueChange={(v) => {
setTab(v as "succeeded" | "failed");
setTab(v as "completed" | "failed");
setPage(1);
}}
className="w-full sm:w-auto"
>
<TabsList className="bg-slate-100/80 p-1 rounded-xl h-11 border border-slate-200/50">
<TabsTrigger
value="succeeded"
value="completed"
className="rounded-lg px-6 gap-2 data-[state=active]:bg-white data-[state=active]:text-emerald-600 data-[state=active]:shadow-sm transition-all font-bold text-xs uppercase"
>
<CheckCircle2 className="h-3.5 w-3.5" />
Succeeded
Completed
</TabsTrigger>
<TabsTrigger
value="failed"
@ -121,7 +121,7 @@ export default function SubscriptionTransactionsPage() {
<div className="text-sm font-medium">
Failed to synchronize with banking ledger. Verify{" "}
<code className="bg-rose-100/50 px-1.5 py-0.5 rounded leading-none">
GET /admin/subscription-transactions
GET /subscription/admin/transactions
</code>{" "}
is reachable.
</div>
@ -238,7 +238,7 @@ export default function SubscriptionTransactionsPage() {
<Badge
className={cn(
"rounded-lg px-3 py-1 text-[10px] font-black uppercase tracking-widest border-none shadow-sm",
row.status === "SUCCEEDED"
row.status === "COMPLETED"
? "bg-emerald-500 text-white shadow-emerald-200/50"
: row.status === "FAILED"
? "bg-rose-500 text-white shadow-rose-200/50"

View File

@ -1,5 +1,5 @@
import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@ -21,19 +21,29 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ArrowLeft, Edit, Key, Loader2 } from "lucide-react";
import { userService } from "@/services";
import { ArrowLeft, Edit, Key, Loader2, CreditCard } from "lucide-react";
import {
userService,
subscriptionService,
type BillingInterval,
} from "@/services";
import { useAdminRole } from "@/hooks/use-admin-role";
import { format } from "date-fns";
import { useState } from "react";
import { toast } from "sonner";
import type { ApiError } from "@/types/error.types";
export default function UserDetailsPage() {
const { id } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { canEditUsers } = useAdminRole();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [assignPlanId, setAssignPlanId] = useState("");
const [assignBillingInterval, setAssignBillingInterval] =
useState<BillingInterval>("MONTHLY");
const [editForm, setEditForm] = useState({
firstName: "",
lastName: "",
@ -52,6 +62,36 @@ export default function UserDetailsPage() {
enabled: !!id,
});
const { data: subscription, isLoading: subscriptionLoading } = useQuery({
queryKey: ["admin", "users", id, "subscription"],
queryFn: () => subscriptionService.getUserSubscription(id!),
enabled: !!id,
});
const { data: plans } = useQuery({
queryKey: ["admin", "subscription-plans"],
queryFn: () => subscriptionService.getAdminPlans(),
});
const assignPlanMutation = useMutation({
mutationFn: () =>
subscriptionService.assignPlan(id!, {
planId: assignPlanId,
billingInterval: assignBillingInterval,
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["admin", "users", id, "subscription"],
});
toast.success("Plan assigned successfully");
setIsAssignDialogOpen(false);
},
onError: (error) => {
const apiError = error as ApiError;
toast.error(apiError.response?.data?.message || "Failed to assign plan");
},
});
const handleEditClick = () => {
if (user) {
setEditForm({
@ -104,6 +144,7 @@ export default function UserDetailsPage() {
<Tabs defaultValue="info" className="space-y-4">
<TabsList>
<TabsTrigger value="info">Information</TabsTrigger>
<TabsTrigger value="subscription">Subscription</TabsTrigger>
<TabsTrigger value="statistics">Statistics</TabsTrigger>
<TabsTrigger
value="activity"
@ -184,6 +225,78 @@ export default function UserDetailsPage() {
</Card>
</TabsContent>
<TabsContent value="subscription" className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Subscription Details</CardTitle>
{canEditUsers && (
<Button
variant="outline"
size="sm"
onClick={() => {
setAssignPlanId(subscription?.plan.id ?? "");
setIsAssignDialogOpen(true);
}}
>
<CreditCard className="w-4 h-4 mr-2" />
Assign Plan
</Button>
)}
</div>
</CardHeader>
<CardContent>
{subscriptionLoading ? (
<div className="flex items-center text-muted-foreground">
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Loading subscription...
</div>
) : subscription ? (
<div className="space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-muted-foreground">Plan</p>
<p className="font-medium">{subscription.plan.displayName}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Status</p>
<Badge>{subscription.subscription.status}</Badge>
</div>
<div>
<p className="text-sm text-muted-foreground">Billing</p>
<p className="font-medium">{subscription.subscription.billingInterval}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Period End</p>
<p className="font-medium">
{format(new Date(subscription.subscription.currentPeriodEnd), 'PP')}
</p>
</div>
</div>
<div>
<p className="text-sm font-medium mb-3">Usage This Period</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{Object.entries(subscription.usage).map(([key, { used, limit }]) => (
<div key={key} className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground capitalize">
{key.replace(/_/g, ' ')}
</p>
<p className="font-medium">
{used} / {limit === null ? '∞' : limit}
</p>
</div>
))}
</div>
</div>
</div>
) : (
<p className="text-muted-foreground">No subscription data available.</p>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="statistics" className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
@ -324,6 +437,63 @@ export default function UserDetailsPage() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isAssignDialogOpen} onOpenChange={setIsAssignDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Assign Subscription Plan</DialogTitle>
<DialogDescription>
Manually assign a plan to this user. Changes take effect immediately.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="plan">Plan</Label>
<Select value={assignPlanId} onValueChange={setAssignPlanId}>
<SelectTrigger>
<SelectValue placeholder="Select a plan" />
</SelectTrigger>
<SelectContent>
{plans?.map((plan) => (
<SelectItem key={plan.id} value={plan.id}>
{plan.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="billingInterval">Billing Interval</Label>
<Select
value={assignBillingInterval}
onValueChange={(value) => setAssignBillingInterval(value as BillingInterval)}
>
<SelectTrigger>
<SelectValue placeholder="Select interval" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MONTHLY">Monthly</SelectItem>
<SelectItem value="YEARLY">Yearly</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAssignDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={() => assignPlanMutation.mutate()}
disabled={!assignPlanId || assignPlanMutation.isPending}
>
{assignPlanMutation.isPending && (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
)}
Assign Plan
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,26 @@
import apiClient from './api/client'
import type {
EmailPreviewResult,
EmailTemplateListResponse,
} from '@/types/email.types'
class EmailService {
async listPreviewTemplates(): Promise<EmailTemplateListResponse> {
const response = await apiClient.get<EmailTemplateListResponse>('/emails/preview/templates')
return response.data
}
async getPreviewJson(templateKey: string): Promise<EmailPreviewResult> {
const response = await apiClient.get<EmailPreviewResult>(
`/emails/preview/${templateKey}/json`,
)
return response.data
}
getPreviewHtmlUrl(templateKey: string): string {
const baseUrl = import.meta.env.VITE_BACKEND_API_URL || 'http://localhost:3001/api/v1'
return `${baseUrl}/emails/preview/${templateKey}`
}
}
export const emailService = new EmailService()

View File

@ -15,6 +15,8 @@ export { subscriptionTransactionService } from "./subscription-transaction.servi
export { systemMemberService } from "./system-member.service";
export { issueService } from "./issue.service";
export { faqService } from "./faq.service";
export { subscriptionService } from "./subscription.service";
export { emailService } from "./email.service";
// Export types
export type { LoginRequest, LoginResponse } from "./auth.service";
@ -73,3 +75,18 @@ export type {
export type { SystemMember, CreateSystemMemberPayload } from "./system-member.service";
export type { SupportIssue, IssueStatus } from "./issue.service";
export type { FaqEntry, FaqAudience } from "./faq.service";
export type {
SubscriptionPlan,
PlanFeatures,
UpdatePlanData,
AssignPlanData,
UserSubscriptionSummary,
SubscriptionTransaction as AdminSubscriptionTransaction,
GetSubscriptionTransactionsParams,
BillingInterval,
ChapaTransactionStatus,
} from "@/types/subscription.types";
export type {
EmailTemplateMeta,
EmailPreviewResult,
} from "@/types/email.types";

View File

@ -1,6 +1,6 @@
import apiClient from "./api/client"
export type SubscriptionPaymentStatus = "SUCCEEDED" | "FAILED" | "PENDING"
export type SubscriptionPaymentStatus = "COMPLETED" | "FAILED" | "PENDING"
export interface SubscriptionTransaction {
id: string
@ -36,7 +36,7 @@ class SubscriptionTransactionService {
filters: SubscriptionTransactionFilters = {},
): Promise<PaginatedSubscriptionTx> {
const response = await apiClient.get<PaginatedSubscriptionTx>(
"/admin/subscription-transactions",
"/subscription/admin/transactions",
{ params: filters },
)
return response.data

View File

@ -0,0 +1,67 @@
import apiClient from './api/client'
import type {
AssignPlanData,
GetSubscriptionTransactionsParams,
PlanFeatures,
SubscriptionPlan,
SubscriptionTransactionsResponse,
UpdatePlanData,
UserSubscriptionSummary,
} from '@/types/subscription.types'
class SubscriptionService {
async getAdminPlans(): Promise<SubscriptionPlan[]> {
const response = await apiClient.get<SubscriptionPlan[]>('/subscription/admin/plans')
return response.data
}
async getAdminPlan(id: string): Promise<SubscriptionPlan> {
const response = await apiClient.get<SubscriptionPlan>(`/subscription/admin/plans/${id}`)
return response.data
}
async updatePlan(id: string, data: UpdatePlanData): Promise<SubscriptionPlan> {
const response = await apiClient.put<SubscriptionPlan>(`/subscription/admin/plans/${id}`, data)
return response.data
}
async updatePlanFeatures(id: string, features: PlanFeatures): Promise<SubscriptionPlan> {
const response = await apiClient.put<SubscriptionPlan>(
`/subscription/admin/plans/${id}/features`,
features,
)
return response.data
}
async getUserSubscription(userId: string): Promise<UserSubscriptionSummary> {
const response = await apiClient.get<UserSubscriptionSummary>(
`/subscription/admin/users/${userId}`,
)
return response.data
}
async assignPlan(userId: string, data: AssignPlanData) {
const response = await apiClient.post(`/subscription/admin/users/${userId}/assign`, data)
return response.data
}
async getSubscriptionTransactions(
params?: GetSubscriptionTransactionsParams,
): Promise<SubscriptionTransactionsResponse> {
const queryParams = params
? {
...params,
page: params.page ? Number(params.page) : undefined,
limit: params.limit ? Number(params.limit) : undefined,
}
: undefined
const response = await apiClient.get<SubscriptionTransactionsResponse>(
'/subscription/admin/transactions',
{ params: queryParams },
)
return response.data
}
}
export const subscriptionService = new SubscriptionService()

18
src/types/email.types.ts Normal file
View File

@ -0,0 +1,18 @@
export interface EmailTemplateMeta {
key: string
name: string
description: string
defaultSubject: string
previewUrl: string
previewJsonUrl: string
}
export interface EmailTemplateListResponse {
templates: EmailTemplateMeta[]
}
export interface EmailPreviewResult {
templateKey: string
subject: string
html: string
}

View File

@ -0,0 +1,117 @@
export type BillingInterval = 'MONTHLY' | 'YEARLY'
export type SubscriptionStatus =
| 'ACTIVE'
| 'PAST_DUE'
| 'CANCELLED'
| 'EXPIRED'
| 'TRIALING'
export type ChapaTransactionStatus =
| 'PENDING'
| 'SUCCESS'
| 'FAILED'
| 'CANCELLED'
export interface PlanFeatures {
features: Record<string, boolean>
limits: Record<string, number | null>
}
export interface SubscriptionPlan {
id: string
name: string
displayName: string
description: string
isFree: boolean
isActive: boolean
sortOrder: number
monthlyPrice: number
yearlyPrice: number
features: PlanFeatures
createdAt?: string
updatedAt?: string
}
export interface UpdatePlanData {
displayName?: string
description?: string
monthlyPrice?: number
yearlyPrice?: number
isActive?: boolean
}
export interface AssignPlanData {
planId: string
billingInterval?: BillingInterval
externalRef?: string
}
export interface UserSubscriptionSummary {
plan: {
id: string
name: string
displayName: string
isFree: boolean
}
subscription: {
id: string
status: SubscriptionStatus
billingInterval: BillingInterval
currentPeriodStart: string
currentPeriodEnd: string
nextBillingAt: string | null
trialEndsAt: string | null
}
features: Record<string, boolean>
usage: Record<string, { used: number; limit: number | null }>
}
export interface SubscriptionTransactionUser {
id: string
email: string
firstName: string | null
lastName: string | null
}
export interface SubscriptionTransactionPlan {
id: string
name: string
displayName: string
}
export interface SubscriptionTransaction {
id: string
txRef: string
checkoutUrl: string | null
totalAmount: number
currency: string
status: ChapaTransactionStatus
purpose: string
billingInterval: BillingInterval | null
userId: string
planId: string | null
createdAt: string
user: SubscriptionTransactionUser
plan: SubscriptionTransactionPlan | null
}
export interface SubscriptionTransactionsResponse {
data: SubscriptionTransaction[]
meta: {
total: number
page: number
limit: number
totalPages: number
hasNextPage: boolean
hasPreviousPage: boolean
}
}
export interface GetSubscriptionTransactionsParams {
page?: number
limit?: number
userId?: string
planId?: string
status?: ChapaTransactionStatus
}