email templates and subs system
This commit is contained in:
parent
a15064ce37
commit
97335f2650
15
src/App.tsx
15
src/App.tsx
|
|
@ -35,6 +35,10 @@ import SystemMembersPage from "@/pages/admin/system-members";
|
||||||
import IssuesPage from "@/pages/admin/issues";
|
import IssuesPage from "@/pages/admin/issues";
|
||||||
import FaqSupportPage from "@/pages/admin/support/faq";
|
import FaqSupportPage from "@/pages/admin/support/faq";
|
||||||
import NotificationBroadcastPage from "@/pages/admin/notifications/broadcast";
|
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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -110,6 +114,17 @@ function App() {
|
||||||
element={<ProformaRequestsPage />}
|
element={<ProformaRequestsPage />}
|
||||||
/>
|
/>
|
||||||
<Route path="admin/health" element={<HealthPage />} />
|
<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 path="notifications" element={<NotificationsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ import {
|
||||||
LifeBuoy,
|
LifeBuoy,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Send,
|
Send,
|
||||||
|
Mail,
|
||||||
|
Layers,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
export interface AdminSearchRoute {
|
export interface AdminSearchRoute {
|
||||||
|
|
@ -103,9 +105,27 @@ export const ADMIN_SEARCH_ROUTES: AdminSearchRoute[] = [
|
||||||
title: "Subscription transactions",
|
title: "Subscription transactions",
|
||||||
description:
|
description:
|
||||||
"Successful and failed subscription charges for the platform.",
|
"Successful and failed subscription charges for the platform.",
|
||||||
group: "Commerce",
|
group: "Subscriptions",
|
||||||
icon: ArrowRightLeft,
|
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",
|
id: "users",
|
||||||
path: "/admin/users",
|
path: "/admin/users",
|
||||||
|
|
@ -299,6 +319,7 @@ export const ADMIN_SEARCH_ROUTES: AdminSearchRoute[] = [
|
||||||
const GROUP_ORDER = [
|
const GROUP_ORDER = [
|
||||||
"Overview",
|
"Overview",
|
||||||
"Commerce",
|
"Commerce",
|
||||||
|
"Subscriptions",
|
||||||
"People & activity",
|
"People & activity",
|
||||||
"Support",
|
"Support",
|
||||||
"Operations",
|
"Operations",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Folder,
|
Folder,
|
||||||
|
Mail,
|
||||||
|
Layers,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AdminQuickSearch } from "@/components/admin-quick-search";
|
import { AdminQuickSearch } from "@/components/admin-quick-search";
|
||||||
|
|
@ -56,6 +58,31 @@ type NavItem = {
|
||||||
visible?: (role: string | undefined) => boolean;
|
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[] = [
|
const adminNavigationItems: NavItem[] = [
|
||||||
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
|
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
|
||||||
{
|
{
|
||||||
|
|
@ -91,8 +118,19 @@ const adminNavigationItems: NavItem[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: ArrowRightLeft,
|
icon: ArrowRightLeft,
|
||||||
label: "Subscription transactions",
|
label: "Subscriptions",
|
||||||
path: "/admin/transactions/subscriptions",
|
children: [
|
||||||
|
{
|
||||||
|
label: "Transactions",
|
||||||
|
path: "/admin/transactions/subscriptions",
|
||||||
|
icon: ArrowRightLeft,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Plans",
|
||||||
|
path: "/admin/subscriptions",
|
||||||
|
icon: Layers,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Users,
|
icon: Users,
|
||||||
|
|
@ -153,9 +191,20 @@ const adminNavigationItems: NavItem[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Send,
|
icon: Send,
|
||||||
label: "Send notification",
|
label: "Communications",
|
||||||
path: "/admin/notifications/broadcast",
|
children: [
|
||||||
visible: (role) => getPermissions(role).canSendNotifications,
|
{
|
||||||
|
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,
|
item,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
isActive,
|
isActive,
|
||||||
|
role,
|
||||||
}: {
|
}: {
|
||||||
item: NavItem;
|
item: NavItem;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
isActive: (path?: string) => boolean;
|
isActive: (path?: string) => boolean;
|
||||||
|
role: string | undefined;
|
||||||
}) => {
|
}) => {
|
||||||
const hasChildren = item.children && item.children.length > 0;
|
const visibleChildren = item.children
|
||||||
const isCurrentlyActive =
|
? filterNavItems(item.children, role)
|
||||||
isActive(item.path) ||
|
: undefined;
|
||||||
(hasChildren && item.children?.some((child) => isActive(child.path)));
|
const hasChildren = visibleChildren && visibleChildren.length > 0;
|
||||||
|
const isCurrentlyActive = navItemIsActive(item, isActive);
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(isCurrentlyActive);
|
const [isOpen, setIsOpen] = useState(isCurrentlyActive);
|
||||||
|
|
||||||
|
|
@ -209,12 +261,13 @@ const SidebarNavItem = ({
|
||||||
</button>
|
</button>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="space-y-1 ml-4 border-l border-slate-200">
|
<div className="space-y-1 ml-4 border-l border-slate-200">
|
||||||
{item.children?.map((child) => (
|
{visibleChildren?.map((child) => (
|
||||||
<SidebarNavItem
|
<SidebarNavItem
|
||||||
key={child.label}
|
key={child.label}
|
||||||
item={child}
|
item={child}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
|
role={role}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -309,13 +362,12 @@ export function AppShell() {
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto">
|
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto">
|
||||||
{adminNavigationItems
|
{filterNavItems(adminNavigationItems, user?.role).map((item) => (
|
||||||
.filter((item) => (item.visible ? item.visible(user?.role) : true))
|
|
||||||
.map((item) => (
|
|
||||||
<SidebarNavItem
|
<SidebarNavItem
|
||||||
key={item.label}
|
key={item.label}
|
||||||
item={item}
|
item={item}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
|
role={user?.role}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
94
src/pages/admin/email-templates/[key].tsx
Normal file
94
src/pages/admin/email-templates/[key].tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
82
src/pages/admin/email-templates/index.tsx
Normal file
82
src/pages/admin/email-templates/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
src/pages/admin/subscriptions/index.tsx
Normal file
105
src/pages/admin/subscriptions/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
250
src/pages/admin/subscriptions/plans/[id].tsx
Normal file
250
src/pages/admin/subscriptions/plans/[id].tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -21,11 +21,11 @@ import type { SubscriptionPaymentStatus } from "@/services/subscription-transact
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export default function SubscriptionTransactionsPage() {
|
export default function SubscriptionTransactionsPage() {
|
||||||
const [tab, setTab] = useState<"succeeded" | "failed">("succeeded");
|
const [tab, setTab] = useState<"completed" | "failed">("completed");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const status: SubscriptionPaymentStatus =
|
const status: SubscriptionPaymentStatus =
|
||||||
tab === "succeeded" ? "SUCCEEDED" : "FAILED";
|
tab === "completed" ? "COMPLETED" : "FAILED";
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["admin", "subscription-transactions", status, page, search],
|
queryKey: ["admin", "subscription-transactions", status, page, search],
|
||||||
|
|
@ -76,18 +76,18 @@ export default function SubscriptionTransactionsPage() {
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
setTab(v as "succeeded" | "failed");
|
setTab(v as "completed" | "failed");
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
<TabsList className="bg-slate-100/80 p-1 rounded-xl h-11 border border-slate-200/50">
|
<TabsList className="bg-slate-100/80 p-1 rounded-xl h-11 border border-slate-200/50">
|
||||||
<TabsTrigger
|
<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"
|
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" />
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
Succeeded
|
Completed
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="failed"
|
value="failed"
|
||||||
|
|
@ -121,7 +121,7 @@ export default function SubscriptionTransactionsPage() {
|
||||||
<div className="text-sm font-medium">
|
<div className="text-sm font-medium">
|
||||||
Failed to synchronize with banking ledger. Verify{" "}
|
Failed to synchronize with banking ledger. Verify{" "}
|
||||||
<code className="bg-rose-100/50 px-1.5 py-0.5 rounded leading-none">
|
<code className="bg-rose-100/50 px-1.5 py-0.5 rounded leading-none">
|
||||||
GET /admin/subscription-transactions
|
GET /subscription/admin/transactions
|
||||||
</code>{" "}
|
</code>{" "}
|
||||||
is reachable.
|
is reachable.
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -238,7 +238,7 @@ export default function SubscriptionTransactionsPage() {
|
||||||
<Badge
|
<Badge
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg px-3 py-1 text-[10px] font-black uppercase tracking-widest border-none shadow-sm",
|
"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"
|
? "bg-emerald-500 text-white shadow-emerald-200/50"
|
||||||
: row.status === "FAILED"
|
: row.status === "FAILED"
|
||||||
? "bg-rose-500 text-white shadow-rose-200/50"
|
? "bg-rose-500 text-white shadow-rose-200/50"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -21,19 +21,29 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { ArrowLeft, Edit, Key, Loader2 } from "lucide-react";
|
import { ArrowLeft, Edit, Key, Loader2, CreditCard } from "lucide-react";
|
||||||
import { userService } from "@/services";
|
import {
|
||||||
|
userService,
|
||||||
|
subscriptionService,
|
||||||
|
type BillingInterval,
|
||||||
|
} from "@/services";
|
||||||
import { useAdminRole } from "@/hooks/use-admin-role";
|
import { useAdminRole } from "@/hooks/use-admin-role";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import type { ApiError } from "@/types/error.types";
|
||||||
|
|
||||||
export default function UserDetailsPage() {
|
export default function UserDetailsPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { canEditUsers } = useAdminRole();
|
const { canEditUsers } = useAdminRole();
|
||||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [assignPlanId, setAssignPlanId] = useState("");
|
||||||
|
const [assignBillingInterval, setAssignBillingInterval] =
|
||||||
|
useState<BillingInterval>("MONTHLY");
|
||||||
const [editForm, setEditForm] = useState({
|
const [editForm, setEditForm] = useState({
|
||||||
firstName: "",
|
firstName: "",
|
||||||
lastName: "",
|
lastName: "",
|
||||||
|
|
@ -52,6 +62,36 @@ export default function UserDetailsPage() {
|
||||||
enabled: !!id,
|
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 = () => {
|
const handleEditClick = () => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setEditForm({
|
setEditForm({
|
||||||
|
|
@ -104,6 +144,7 @@ export default function UserDetailsPage() {
|
||||||
<Tabs defaultValue="info" className="space-y-4">
|
<Tabs defaultValue="info" className="space-y-4">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="info">Information</TabsTrigger>
|
<TabsTrigger value="info">Information</TabsTrigger>
|
||||||
|
<TabsTrigger value="subscription">Subscription</TabsTrigger>
|
||||||
<TabsTrigger value="statistics">Statistics</TabsTrigger>
|
<TabsTrigger value="statistics">Statistics</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="activity"
|
value="activity"
|
||||||
|
|
@ -184,6 +225,78 @@ export default function UserDetailsPage() {
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</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">
|
<TabsContent value="statistics" className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -324,6 +437,63 @@ export default function UserDetailsPage() {
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
26
src/services/email.service.ts
Normal file
26
src/services/email.service.ts
Normal 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()
|
||||||
|
|
@ -15,6 +15,8 @@ export { subscriptionTransactionService } from "./subscription-transaction.servi
|
||||||
export { systemMemberService } from "./system-member.service";
|
export { systemMemberService } from "./system-member.service";
|
||||||
export { issueService } from "./issue.service";
|
export { issueService } from "./issue.service";
|
||||||
export { faqService } from "./faq.service";
|
export { faqService } from "./faq.service";
|
||||||
|
export { subscriptionService } from "./subscription.service";
|
||||||
|
export { emailService } from "./email.service";
|
||||||
|
|
||||||
// Export types
|
// Export types
|
||||||
export type { LoginRequest, LoginResponse } from "./auth.service";
|
export type { LoginRequest, LoginResponse } from "./auth.service";
|
||||||
|
|
@ -73,3 +75,18 @@ export type {
|
||||||
export type { SystemMember, CreateSystemMemberPayload } from "./system-member.service";
|
export type { SystemMember, CreateSystemMemberPayload } from "./system-member.service";
|
||||||
export type { SupportIssue, IssueStatus } from "./issue.service";
|
export type { SupportIssue, IssueStatus } from "./issue.service";
|
||||||
export type { FaqEntry, FaqAudience } from "./faq.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";
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import apiClient from "./api/client"
|
import apiClient from "./api/client"
|
||||||
|
|
||||||
export type SubscriptionPaymentStatus = "SUCCEEDED" | "FAILED" | "PENDING"
|
export type SubscriptionPaymentStatus = "COMPLETED" | "FAILED" | "PENDING"
|
||||||
|
|
||||||
export interface SubscriptionTransaction {
|
export interface SubscriptionTransaction {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -36,7 +36,7 @@ class SubscriptionTransactionService {
|
||||||
filters: SubscriptionTransactionFilters = {},
|
filters: SubscriptionTransactionFilters = {},
|
||||||
): Promise<PaginatedSubscriptionTx> {
|
): Promise<PaginatedSubscriptionTx> {
|
||||||
const response = await apiClient.get<PaginatedSubscriptionTx>(
|
const response = await apiClient.get<PaginatedSubscriptionTx>(
|
||||||
"/admin/subscription-transactions",
|
"/subscription/admin/transactions",
|
||||||
{ params: filters },
|
{ params: filters },
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
|
|
|
||||||
67
src/services/subscription.service.ts
Normal file
67
src/services/subscription.service.ts
Normal 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
18
src/types/email.types.ts
Normal 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
|
||||||
|
}
|
||||||
117
src/types/subscription.types.ts
Normal file
117
src/types/subscription.types.ts
Normal 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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user