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 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 />} />
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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";
|
||||
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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 { 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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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