-
+
@@ -16,8 +15,6 @@ export function ContentManagementLayout() {
-
-
diff --git a/src/pages/payments/PaymentsPage.tsx b/src/pages/payments/PaymentsPage.tsx
new file mode 100644
index 0000000..f370e1e
--- /dev/null
+++ b/src/pages/payments/PaymentsPage.tsx
@@ -0,0 +1,589 @@
+import { useCallback, useEffect, useState, type ReactNode } from "react"
+import {
+ ChevronDown,
+ ChevronLeft,
+ ChevronRight,
+ Copy,
+ CreditCard,
+ Eye,
+ RefreshCw,
+ TrendingUp,
+ Wallet,
+} from "lucide-react"
+import { Link } from "react-router-dom"
+import { toast } from "sonner"
+import { getPayments } from "../../api/payments.api"
+import { Badge } from "../../components/ui/badge"
+import { Button } from "../../components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "../../components/ui/dialog"
+import { SpinnerIcon } from "../../components/ui/spinner-icon"
+import { cn } from "../../lib/utils"
+import {
+ formatPaymentAmount,
+ formatPaymentDate,
+ formatPaymentMethod,
+ formatPaymentPlanCategory,
+ formatPaymentStatus,
+ paymentCustomerName,
+ paymentStatusBadgeVariant,
+} from "../../lib/payments"
+import type {
+ Payment,
+ PaymentPlanCategory,
+ PaymentProvider,
+ PaymentStatus,
+} from "../../types/payment.types"
+
+const PAGE_SIZE_OPTIONS = [20, 50, 100] as const
+
+const STATUS_FILTERS: { value: PaymentStatus; label: string }[] = [
+ { value: "SUCCESS", label: "Success" },
+ { value: "PENDING", label: "Pending" },
+ { value: "FAILED", label: "Failed" },
+]
+
+const PROVIDER_FILTERS: { value: PaymentProvider; label: string }[] = [
+ { value: "CHAPA", label: "Chapa" },
+ { value: "ARIFPAY", label: "Arifpay" },
+]
+
+const PLAN_CATEGORY_FILTERS: { value: PaymentPlanCategory; label: string }[] = [
+ { value: "LEARN_ENGLISH", label: "Learn English" },
+ { value: "IELTS", label: "IELTS" },
+ { value: "DUOLINGO", label: "Duolingo" },
+]
+
+type PaymentListFilters = {
+ status: PaymentStatus | ""
+ provider: PaymentProvider | ""
+ planCategory: PaymentPlanCategory | ""
+}
+
+function copyText(value: string, label: string) {
+ if (!value) return
+ void navigator.clipboard.writeText(value)
+ toast.success(`${label} copied`)
+}
+
+export function PaymentsPage() {
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(false)
+ const [payments, setPayments] = useState
([])
+ const [totalCount, setTotalCount] = useState(0)
+ const [offset, setOffset] = useState(0)
+ const [pageSize, setPageSize] = useState(20)
+ const [statusFilter, setStatusFilter] = useState("")
+ const [providerFilter, setProviderFilter] = useState("")
+ const [planCategoryFilter, setPlanCategoryFilter] = useState("")
+ const [selected, setSelected] = useState(null)
+
+ const listFilters: PaymentListFilters = {
+ status: statusFilter,
+ provider: providerFilter,
+ planCategory: planCategoryFilter,
+ }
+
+ const hasActiveFilters = Boolean(
+ listFilters.status || listFilters.provider || listFilters.planCategory,
+ )
+
+ const fetchPayments = useCallback(
+ async (nextOffset: number, limit: number, filters: PaymentListFilters) => {
+ setLoading(true)
+ setError(false)
+ try {
+ const res = await getPayments({
+ limit,
+ offset: nextOffset,
+ ...(filters.status ? { status: filters.status } : {}),
+ ...(filters.provider ? { provider: filters.provider } : {}),
+ ...(filters.planCategory ? { plan_category: filters.planCategory } : {}),
+ })
+ setPayments(res.data.payments)
+ setTotalCount(res.data.total_count)
+ } catch (e) {
+ console.error(e)
+ setError(true)
+ setPayments([])
+ setTotalCount(0)
+ toast.error("Failed to load payments")
+ } finally {
+ setLoading(false)
+ }
+ },
+ [],
+ )
+
+ useEffect(() => {
+ void fetchPayments(offset, pageSize, listFilters)
+ }, [offset, pageSize, statusFilter, providerFilter, planCategoryFilter, fetchPayments])
+
+ const toggleStatus = (value: PaymentStatus) => {
+ setStatusFilter((current) => (current === value ? "" : value))
+ setOffset(0)
+ }
+
+ const toggleProvider = (value: PaymentProvider) => {
+ setProviderFilter((current) => (current === value ? "" : value))
+ setOffset(0)
+ }
+
+ const togglePlanCategory = (value: PaymentPlanCategory) => {
+ setPlanCategoryFilter((current) => (current === value ? "" : value))
+ setOffset(0)
+ }
+
+ const clearFilters = () => {
+ setStatusFilter("")
+ setProviderFilter("")
+ setPlanCategoryFilter("")
+ setOffset(0)
+ }
+
+ const successfulOnPage = payments.filter((p) => p.status.toUpperCase() === "SUCCESS")
+ const pageRevenue = successfulOnPage.reduce((sum, p) => sum + (Number(p.amount) || 0), 0)
+ const pendingOnPage = payments.filter((p) => {
+ const s = p.status.toUpperCase()
+ return s === "PENDING" || s === "PROCESSING"
+ }).length
+
+ const pageStart = totalCount === 0 ? 0 : offset + 1
+ const pageEnd = Math.min(offset + payments.length, totalCount)
+ const canPrev = offset > 0
+ const canNext = offset + pageSize < totalCount
+
+ return (
+
+
+
+
+ Billing
+
+
Payments
+
+ Browse checkout transactions from{" "}
+ GET /admin/payments.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Total transactions
+
{totalCount}
+
+
+
+
+
+
+
+
+
+
+
Successful (this page)
+
{successfulOnPage.length}
+
+
+
+
+
+
+
+
+
+
+
Revenue (this page)
+
+ {pageRevenue.toLocaleString()} ETB
+
+
{pendingOnPage} pending on page
+
+
+
+
+
+
+
+ Transaction history
+
+
+
+
+
+ Status
+
+ {STATUS_FILTERS.map(({ value, label }) => (
+ toggleStatus(value)}
+ />
+ ))}
+
+
+
+ Provider
+
+ {PROVIDER_FILTERS.map(({ value, label }) => (
+ toggleProvider(value)}
+ />
+ ))}
+
+
+
+ Plan
+
+ {PLAN_CATEGORY_FILTERS.map(({ value, label }) => (
+ togglePlanCategory(value)}
+ />
+ ))}
+ {hasActiveFilters ? (
+
+ ) : null}
+
+
+
+ {loading ? (
+
+ ) : error ? (
+
+
Could not load payments
+
+
+ ) : payments.length === 0 ? (
+
+
+
+ {hasActiveFilters ? "No payments match these filters" : "No payments yet"}
+
+
+ {hasActiveFilters
+ ? "Try different filters or clear them to see more results."
+ : "Transactions will appear here once customers complete checkout."}
+
+ {hasActiveFilters ? (
+
+ ) : null}
+
+ ) : (
+
+
+
+
+ | Transaction |
+ Customer |
+ Plan |
+ Amount |
+ Method |
+ Status |
+ Paid |
+
+ Actions
+ |
+
+
+
+ {payments.map((payment) => (
+
+ |
+ #{payment.id}
+
+ {payment.transaction_id || payment.session_id || "—"}
+
+ |
+
+
+ {paymentCustomerName(payment)}
+
+
+ {payment.user_email || `User #${payment.user_id}`}
+
+ |
+
+
+ {payment.plan_name || `Plan #${payment.plan_id}`}
+
+ {payment.plan_category ? (
+
+ {formatPaymentPlanCategory(payment.plan_category)}
+
+ ) : null}
+ |
+
+ {formatPaymentAmount(payment)}
+ |
+
+ {formatPaymentMethod(payment.payment_method)}
+ |
+
+
+ {formatPaymentStatus(payment.status)}
+
+ |
+
+ {formatPaymentDate(payment.paid_at ?? payment.created_at)}
+ |
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+ {!loading && !error && totalCount > 0 ? (
+
+
+
+ Showing {pageStart}–{pageEnd} of {totalCount}
+
+
+
+ Rows per page
+
+
+
+
+
+
+
+
+
+
+
+ ) : null}
+
+
+
+
+
+ )
+}
+
+function FilterChip({
+ label,
+ active,
+ disabled,
+ onClick,
+}: {
+ label: string
+ active: boolean
+ disabled?: boolean
+ onClick: () => void
+}) {
+ return (
+
+ )
+}
+
+function Detail({
+ label,
+ value,
+ mono,
+}: {
+ label: string
+ value: ReactNode
+ mono?: boolean
+}) {
+ return (
+
+
{label}
+
+ {value}
+
+
+ )
+}
diff --git a/src/pages/settings/AppVersionsTab.tsx b/src/pages/settings/AppVersionsTab.tsx
new file mode 100644
index 0000000..0f2aac6
--- /dev/null
+++ b/src/pages/settings/AppVersionsTab.tsx
@@ -0,0 +1,501 @@
+import { useCallback, useEffect, useMemo, useState } from "react"
+import {
+ AlertTriangle,
+ Apple,
+ Calendar,
+ ChevronLeft,
+ ChevronRight,
+ ExternalLink,
+ Pencil,
+ Plus,
+ RefreshCw,
+ Search,
+ Smartphone,
+ TabletSmartphone,
+ Trash2,
+} from "lucide-react"
+import { toast } from "sonner"
+import { getAppVersions } from "../../api/app-versions.api"
+import { Badge } from "../../components/ui/badge"
+import { Button } from "../../components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
+import { Input } from "../../components/ui/input"
+import { SpinnerIcon } from "../../components/ui/spinner-icon"
+import { cn } from "../../lib/utils"
+import {
+ formatAppPlatform,
+ formatAppVersionCreatedAt,
+ formatUpdateType,
+ formatVersionStatus,
+ versionLabel,
+} from "../../lib/appVersions"
+import type { AppPlatform, AppVersion, AppVersionStatus } from "../../types/app-version.types"
+import { CreateAppVersionDialog } from "./components/CreateAppVersionDialog"
+import { DeleteAppVersionDialog } from "./components/DeleteAppVersionDialog"
+import { EditAppVersionDialog } from "./components/EditAppVersionDialog"
+
+const PAGE_SIZE = 20
+
+function PlatformIcon({ platform }: { platform: string }) {
+ const upper = platform.toUpperCase()
+ if (upper === "IOS") {
+ return
+ }
+ return
+}
+
+function updateTypeBadgeVariant(updateType: string): "destructive" | "warning" | "info" | "secondary" {
+ const t = updateType.toUpperCase()
+ if (t === "FORCE") return "destructive"
+ if (t === "SOFT") return "warning"
+ return "info"
+}
+
+function statusBadgeVariant(status: string): "success" | "secondary" | "warning" {
+ const s = status.toUpperCase()
+ if (s === "ACTIVE") return "success"
+ if (s === "DRAFT") return "warning"
+ return "secondary"
+}
+
+export function AppVersionsTab() {
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(false)
+ const [versions, setVersions] = useState([])
+ const [totalCount, setTotalCount] = useState(0)
+ const [offset, setOffset] = useState(0)
+ const [query, setQuery] = useState("")
+ const [platformFilter, setPlatformFilter] = useState<"all" | AppPlatform>("all")
+ const [statusFilter, setStatusFilter] = useState<"all" | AppVersionStatus>("all")
+ const [createOpen, setCreateOpen] = useState(false)
+ const [versionToEdit, setVersionToEdit] = useState(null)
+ const [versionToDelete, setVersionToDelete] = useState(null)
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ setError(false)
+ try {
+ const res = await getAppVersions({ limit: PAGE_SIZE, offset })
+ setVersions(res.data.versions)
+ setTotalCount(res.data.total_count)
+ } catch (e) {
+ console.error(e)
+ setError(true)
+ setVersions([])
+ setTotalCount(0)
+ toast.error("Failed to load app versions")
+ } finally {
+ setLoading(false)
+ }
+ }, [offset])
+
+ useEffect(() => {
+ void load()
+ }, [load])
+
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase()
+ return [...versions]
+ .filter((v) => {
+ if (platformFilter !== "all" && v.platform !== platformFilter) return false
+ if (statusFilter !== "all" && v.status !== statusFilter) return false
+ if (!q) return true
+ const haystack = [
+ v.version_name,
+ String(v.version_code),
+ v.platform,
+ v.update_type,
+ v.release_notes,
+ v.status,
+ ]
+ .join(" ")
+ .toLowerCase()
+ return haystack.includes(q)
+ })
+ .sort((a, b) => b.version_code - a.version_code)
+ }, [versions, query, platformFilter, statusFilter])
+
+ const androidCount = versions.filter((v) => v.platform.toUpperCase() === "ANDROID").length
+ const iosCount = versions.filter((v) => v.platform.toUpperCase() === "IOS").length
+ const activeCount = versions.filter((v) => v.status.toUpperCase() === "ACTIVE").length
+ const forceCount = versions.filter((v) => v.update_type.toUpperCase() === "FORCE").length
+
+ const pageStart = totalCount === 0 ? 0 : offset + 1
+ const pageEnd = Math.min(offset + versions.length, totalCount)
+ const canPrev = offset > 0
+ const canNext = offset + PAGE_SIZE < totalCount
+
+ const handleCreated = (version: AppVersion) => {
+ if (offset === 0) {
+ setVersions((prev) => {
+ const without = prev.filter((v) => v.id !== version.id)
+ return [version, ...without]
+ })
+ setTotalCount((c) => c + 1)
+ } else {
+ setOffset(0)
+ }
+ }
+
+ const handleUpdated = (version: AppVersion) => {
+ setVersions((prev) => prev.map((v) => (v.id === version.id ? version : v)))
+ }
+
+ const handleDeleted = (id: number) => {
+ setVersions((prev) => prev.filter((v) => v.id !== id))
+ setTotalCount((c) => Math.max(0, c - 1))
+ }
+
+ return (
+
+
+
+
+ Mobile releases
+
+
App version control
+
+ Manage Android and iOS release metadata for in-app update prompts. Versions are loaded
+ from{" "}
+
+ GET /admin/app-versions
+
+ .
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Total (this page)
+
{totalCount}
+
+
+
+
+
+
+
+
+
+
+
Android · iOS
+
+ {androidCount} · {iosCount}
+
+
+
+
+
+
+
+
+
+
+
+
Active on page
+
{activeCount}
+
+
+
+
+
+
+
+
+
Force updates
+
{forceCount}
+
+
+
+
+
+
+
+ Release history
+
+
+
+
+
+ setQuery(e.target.value)}
+ />
+
+
+ {(
+ [
+ { id: "all", label: "All platforms" },
+ { id: "ANDROID", label: "Android" },
+ { id: "IOS", label: "iOS" },
+ ] as const
+ ).map((tab) => (
+
+ ))}
+
+
+ {(
+ [
+ { id: "all", label: "All statuses" },
+ { id: "ACTIVE", label: "Active" },
+ { id: "INACTIVE", label: "Inactive" },
+ { id: "DRAFT", label: "Draft" },
+ ] as const
+ ).map((tab) => (
+
+ ))}
+
+
+
+ {loading ? (
+
+
+
Loading app versions…
+
+ ) : error ? (
+
+
Could not load versions
+
+
+ ) : filtered.length === 0 ? (
+
+
+
+ {versions.length === 0 ? "No app versions yet" : "No versions match your filters"}
+
+
+ {versions.length === 0
+ ? "Publish your first Android or iOS release to control learner update prompts."
+ : "Try a different search or filter."}
+
+ {versions.length === 0 ? (
+
+ ) : null}
+
+ ) : (
+
+
+
+
+ | Release |
+ Platform |
+ Update |
+ Min |
+ Status |
+ Notes |
+ Published |
+
+ Actions
+ |
+
+
+
+ {filtered.map((version) => (
+
+ |
+
+ {versionLabel(version)}
+
+ ID {version.id}
+ |
+
+
+
+ {formatAppPlatform(version.platform)}
+
+ |
+
+
+ {formatUpdateType(version.update_type)}
+
+ |
+
+ {version.min_supported_version_code}
+ |
+
+
+ {formatVersionStatus(version.status)}
+
+ |
+
+
+ {version.release_notes}
+
+ |
+
+
+
+ {formatAppVersionCreatedAt(version.created_at)}
+
+ |
+
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+ {!loading && !error && totalCount > 0 ? (
+
+
+ Showing {pageStart}–{pageEnd} of {totalCount}
+
+
+
+
+
+
+ ) : null}
+
+
+
+
+
+
{
+ if (!open) setVersionToEdit(null)
+ }}
+ onUpdated={handleUpdated}
+ />
+
+ {
+ if (!open) setVersionToDelete(null)
+ }}
+ onDeleted={handleDeleted}
+ />
+
+ )
+}
diff --git a/src/pages/settings/SubscriptionPlansTab.tsx b/src/pages/settings/SubscriptionPlansTab.tsx
new file mode 100644
index 0000000..0a16e04
--- /dev/null
+++ b/src/pages/settings/SubscriptionPlansTab.tsx
@@ -0,0 +1,343 @@
+import { useCallback, useEffect, useMemo, useState } from "react"
+import {
+ Calendar,
+ CreditCard,
+ Package,
+ Pencil,
+ Plus,
+ RefreshCw,
+ Search,
+ Tag,
+ Trash2,
+} from "lucide-react"
+import { toast } from "sonner"
+import { getSubscriptionPlans } from "../../api/subscription-plans.api"
+import { Badge } from "../../components/ui/badge"
+import { Button } from "../../components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"
+import { Input } from "../../components/ui/input"
+import { SpinnerIcon } from "../../components/ui/spinner-icon"
+import { cn } from "../../lib/utils"
+import {
+ formatPlanCategory,
+ formatPlanCreatedAt,
+ formatPlanDuration,
+ formatPlanPrice,
+} from "../../lib/subscriptionPlans"
+import type { SubscriptionPlan } from "../../types/subscription.types"
+import { CreateSubscriptionPlanDialog } from "./components/CreateSubscriptionPlanDialog"
+import { DeleteSubscriptionPlanDialog } from "./components/DeleteSubscriptionPlanDialog"
+import { EditSubscriptionPlanDialog } from "./components/EditSubscriptionPlanDialog"
+
+export function SubscriptionPlansTab() {
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(false)
+ const [plans, setPlans] = useState([])
+ const [query, setQuery] = useState("")
+ const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all")
+ const [createOpen, setCreateOpen] = useState(false)
+ const [planToEdit, setPlanToEdit] = useState(null)
+ const [planToDelete, setPlanToDelete] = useState(null)
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ setError(false)
+ try {
+ const res = await getSubscriptionPlans()
+ setPlans(res.data)
+ } catch (e) {
+ console.error(e)
+ setError(true)
+ setPlans([])
+ toast.error("Failed to load subscription packages")
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ void load()
+ }, [load])
+
+ const filtered = useMemo(() => {
+ const q = query.trim().toLowerCase()
+ return [...plans]
+ .filter((plan) => {
+ if (statusFilter === "active" && !plan.is_active) return false
+ if (statusFilter === "inactive" && plan.is_active) return false
+ if (!q) return true
+ const haystack = [plan.name, plan.description, plan.category, plan.currency]
+ .join(" ")
+ .toLowerCase()
+ return haystack.includes(q)
+ })
+ .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
+ }, [plans, query, statusFilter])
+
+ const activeCount = plans.filter((p) => p.is_active).length
+
+ const handleCreated = (plan: SubscriptionPlan) => {
+ setPlans((prev) => {
+ const without = prev.filter((p) => p.id !== plan.id)
+ return [plan, ...without]
+ })
+ }
+
+ const handleUpdated = (plan: SubscriptionPlan) => {
+ setPlans((prev) => prev.map((p) => (p.id === plan.id ? plan : p)))
+ }
+
+ const handleDeleted = (id: number) => {
+ setPlans((prev) => prev.filter((p) => p.id !== id))
+ }
+
+ return (
+
+
+
+
+ Billing & catalog
+
+
Subscription packages
+
+ Manage learner subscription plans from{" "}
+ GET /subscription-plans
+ . Create, edit, or remove packages for the learner checkout flow.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Total packages
+
{plans.length}
+
+
+
+
+
+
+
+
+
+
+
Active
+
{activeCount}
+
+
+
+
+
+
+
+
+
+
+
Inactive
+
{plans.length - activeCount}
+
+
+
+
+
+
+
+ All packages
+
+
+
+
+
+ setQuery(e.target.value)}
+ />
+
+
+ {(
+ [
+ { id: "all", label: "All" },
+ { id: "active", label: "Active" },
+ { id: "inactive", label: "Inactive" },
+ ] as const
+ ).map((tab) => (
+
+ ))}
+
+
+
+ {loading ? (
+
+ ) : error ? (
+
+
Could not load packages
+
+
+ ) : filtered.length === 0 ? (
+
+
+
+ {plans.length === 0 ? "No subscription packages yet" : "No packages match your filters"}
+
+
+ {plans.length === 0
+ ? "Create your first package to offer paid access in the learner app."
+ : "Try a different search or status filter."}
+
+ {plans.length === 0 ? (
+
+ ) : null}
+
+ ) : (
+
+
+
+
+ | Package |
+ Category |
+ Duration |
+ Price |
+ Status |
+ Created |
+ Actions |
+
+
+
+ {filtered.map((plan) => (
+
+ |
+ {plan.name}
+
+ {plan.description}
+
+ |
+
+
+ {formatPlanCategory(plan.category)}
+
+ |
+
+ {formatPlanDuration(plan)}
+ |
+
+ {formatPlanPrice(plan)}
+ |
+
+
+ {plan.is_active ? "Active" : "Inactive"}
+
+ |
+
+
+
+ {formatPlanCreatedAt(plan.created_at)}
+
+ |
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+
{
+ if (!open) setPlanToEdit(null)
+ }}
+ onUpdated={handleUpdated}
+ />
+
+ {
+ if (!open) setPlanToDelete(null)
+ }}
+ onDeleted={handleDeleted}
+ />
+
+ )
+}
diff --git a/src/pages/settings/components/CreateAppVersionDialog.tsx b/src/pages/settings/components/CreateAppVersionDialog.tsx
new file mode 100644
index 0000000..ba6e5db
--- /dev/null
+++ b/src/pages/settings/components/CreateAppVersionDialog.tsx
@@ -0,0 +1,321 @@
+import { useEffect, useState } from "react"
+import { Plus } from "lucide-react"
+import { toast } from "sonner"
+import { createAppVersion } from "../../../api/app-versions.api"
+import { Button } from "../../../components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "../../../components/ui/dialog"
+import { Input } from "../../../components/ui/input"
+import { Select } from "../../../components/ui/select"
+import { SpinnerIcon } from "../../../components/ui/spinner-icon"
+import { Textarea } from "../../../components/ui/textarea"
+import {
+ APP_PLATFORMS,
+ APP_UPDATE_TYPES,
+ APP_VERSION_STATUSES,
+ DEFAULT_STORE_URLS,
+} from "../../../lib/appVersions"
+import type {
+ AppPlatform,
+ AppUpdateType,
+ AppVersion,
+ AppVersionStatus,
+ CreateAppVersionPayload,
+} from "../../../types/app-version.types"
+
+export interface CreateAppVersionDraft {
+ platform: AppPlatform
+ version_name: string
+ version_code: string
+ update_type: AppUpdateType
+ release_notes: string
+ store_url: string
+ min_supported_version_code: string
+ status: AppVersionStatus
+}
+
+export const EMPTY_APP_VERSION_DRAFT: CreateAppVersionDraft = {
+ platform: "ANDROID",
+ version_name: "",
+ version_code: "",
+ update_type: "FORCE",
+ release_notes: "",
+ store_url: DEFAULT_STORE_URLS.ANDROID,
+ min_supported_version_code: "",
+ status: "ACTIVE",
+}
+
+function draftToPayload(draft: CreateAppVersionDraft): CreateAppVersionPayload | null {
+ const version_name = draft.version_name.trim()
+ const version_code = Number(draft.version_code)
+ const min_supported_version_code = Number(draft.min_supported_version_code)
+ const release_notes = draft.release_notes.trim()
+ const store_url = draft.store_url.trim()
+
+ if (!version_name) return null
+ if (!Number.isFinite(version_code) || version_code < 1) return null
+ if (!Number.isFinite(min_supported_version_code) || min_supported_version_code < 0) return null
+ if (!release_notes) return null
+ if (!store_url) return null
+
+ try {
+ new URL(store_url)
+ } catch {
+ return null
+ }
+
+ return {
+ platform: draft.platform,
+ version_name,
+ version_code,
+ update_type: draft.update_type,
+ release_notes,
+ store_url,
+ min_supported_version_code,
+ status: draft.status,
+ }
+}
+
+type CreateAppVersionDialogProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onCreated: (version: AppVersion) => void
+}
+
+export function CreateAppVersionDialog({
+ open,
+ onOpenChange,
+ onCreated,
+}: CreateAppVersionDialogProps) {
+ const [draft, setDraft] = useState(EMPTY_APP_VERSION_DRAFT)
+ const [saving, setSaving] = useState(false)
+
+ useEffect(() => {
+ if (!open) {
+ setDraft(EMPTY_APP_VERSION_DRAFT)
+ setSaving(false)
+ }
+ }, [open])
+
+ const handlePlatformChange = (platform: AppPlatform) => {
+ setDraft((d) => ({
+ ...d,
+ platform,
+ store_url:
+ d.store_url === DEFAULT_STORE_URLS.ANDROID || d.store_url === DEFAULT_STORE_URLS.IOS
+ ? DEFAULT_STORE_URLS[platform] ?? d.store_url
+ : d.store_url,
+ }))
+ }
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ const payload = draftToPayload(draft)
+ if (!payload) {
+ toast.error("Please fill in all required fields with valid values.")
+ return
+ }
+
+ setSaving(true)
+ try {
+ const res = await createAppVersion(payload)
+ if (!res.data) {
+ toast.error("Version was created but the response could not be read.")
+ return
+ }
+ toast.success(res.message || "App version created successfully")
+ onCreated(res.data)
+ onOpenChange(false)
+ } catch {
+ toast.error("Failed to create app version.")
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/src/pages/settings/components/CreateSubscriptionPlanDialog.tsx b/src/pages/settings/components/CreateSubscriptionPlanDialog.tsx
new file mode 100644
index 0000000..96a770e
--- /dev/null
+++ b/src/pages/settings/components/CreateSubscriptionPlanDialog.tsx
@@ -0,0 +1,308 @@
+import { useEffect, useState } from "react"
+import { Plus } from "lucide-react"
+import { toast } from "sonner"
+import { createSubscriptionPlan } from "../../../api/subscription-plans.api"
+import { Button } from "../../../components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "../../../components/ui/dialog"
+import { Input } from "../../../components/ui/input"
+import { Select } from "../../../components/ui/select"
+import { SpinnerIcon } from "../../../components/ui/spinner-icon"
+import { Textarea } from "../../../components/ui/textarea"
+import { cn } from "../../../lib/utils"
+import {
+ SUBSCRIPTION_CURRENCIES,
+ SUBSCRIPTION_DURATION_UNITS,
+ SUBSCRIPTION_PLAN_CATEGORIES,
+} from "../../../lib/subscriptionPlans"
+import type {
+ CreateSubscriptionPlanPayload,
+ SubscriptionPlan,
+ SubscriptionPlanCategory,
+ SubscriptionPlanDurationUnit,
+} from "../../../types/subscription.types"
+
+export interface CreateSubscriptionPlanDraft {
+ name: string
+ description: string
+ category: SubscriptionPlanCategory
+ duration_value: string
+ duration_unit: SubscriptionPlanDurationUnit
+ price: string
+ currency: string
+ is_active: boolean
+}
+
+export const EMPTY_SUBSCRIPTION_PLAN_DRAFT: CreateSubscriptionPlanDraft = {
+ name: "",
+ description: "",
+ category: "LEARN_ENGLISH",
+ duration_value: "1",
+ duration_unit: "MONTH",
+ price: "",
+ currency: "ETB",
+ is_active: true,
+}
+
+function draftToPayload(draft: CreateSubscriptionPlanDraft): CreateSubscriptionPlanPayload | null {
+ const name = draft.name.trim()
+ const description = draft.description.trim()
+ const duration_value = Number(draft.duration_value)
+ const price = Number(draft.price)
+
+ if (!name) return null
+ if (!description) return null
+ if (!Number.isFinite(duration_value) || duration_value < 1) return null
+ if (!Number.isFinite(price) || price < 0) return null
+
+ return {
+ name,
+ description,
+ category: draft.category,
+ duration_value,
+ duration_unit: draft.duration_unit,
+ price,
+ currency: draft.currency,
+ is_active: draft.is_active,
+ }
+}
+
+type CreateSubscriptionPlanDialogProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onCreated: (plan: SubscriptionPlan) => void
+}
+
+export function CreateSubscriptionPlanDialog({
+ open,
+ onOpenChange,
+ onCreated,
+}: CreateSubscriptionPlanDialogProps) {
+ const [draft, setDraft] = useState(EMPTY_SUBSCRIPTION_PLAN_DRAFT)
+ const [saving, setSaving] = useState(false)
+
+ useEffect(() => {
+ if (!open) {
+ setDraft(EMPTY_SUBSCRIPTION_PLAN_DRAFT)
+ setSaving(false)
+ }
+ }, [open])
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ const payload = draftToPayload(draft)
+ if (!payload) {
+ toast.error("Please fill in all required fields with valid values.")
+ return
+ }
+
+ setSaving(true)
+ try {
+ const res = await createSubscriptionPlan(payload)
+ if (!res.data) {
+ toast.error("Plan was created but the response could not be read.")
+ return
+ }
+ toast.success(res.message || "Subscription plan created successfully")
+ onCreated(res.data)
+ onOpenChange(false)
+ } catch {
+ toast.error("Failed to create subscription plan.")
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/src/pages/settings/components/DeleteAppVersionDialog.tsx b/src/pages/settings/components/DeleteAppVersionDialog.tsx
new file mode 100644
index 0000000..4f0cadf
--- /dev/null
+++ b/src/pages/settings/components/DeleteAppVersionDialog.tsx
@@ -0,0 +1,98 @@
+import { useState } from "react"
+import { Trash2 } from "lucide-react"
+import { toast } from "sonner"
+import { deleteAppVersion } from "../../../api/app-versions.api"
+import { Button } from "../../../components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "../../../components/ui/dialog"
+import { formatAppPlatform, versionLabel } from "../../../lib/appVersions"
+import type { AppVersion } from "../../../types/app-version.types"
+
+type DeleteAppVersionDialogProps = {
+ version: AppVersion | null
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onDeleted: (id: number) => void
+}
+
+export function DeleteAppVersionDialog({
+ version,
+ open,
+ onOpenChange,
+ onDeleted,
+}: DeleteAppVersionDialogProps) {
+ const [deleting, setDeleting] = useState(false)
+
+ const handleOpenChange = (next: boolean) => {
+ if (!next && !deleting) onOpenChange(false)
+ }
+
+ const handleConfirm = async () => {
+ if (!version) return
+
+ setDeleting(true)
+ try {
+ const res = await deleteAppVersion(version.id)
+ toast.success(res.message || "App version deleted successfully")
+ onDeleted(version.id)
+ onOpenChange(false)
+ } catch (e: unknown) {
+ console.error(e)
+ const msg =
+ (e as { response?: { data?: { message?: string } } })?.response?.data?.message ??
+ "Failed to delete app version"
+ toast.error(msg)
+ } finally {
+ setDeleting(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/src/pages/settings/components/DeleteSubscriptionPlanDialog.tsx b/src/pages/settings/components/DeleteSubscriptionPlanDialog.tsx
new file mode 100644
index 0000000..9ed495d
--- /dev/null
+++ b/src/pages/settings/components/DeleteSubscriptionPlanDialog.tsx
@@ -0,0 +1,98 @@
+import { useState } from "react"
+import { Trash2 } from "lucide-react"
+import { toast } from "sonner"
+import { deleteSubscriptionPlan } from "../../../api/subscription-plans.api"
+import { Button } from "../../../components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "../../../components/ui/dialog"
+import { formatPlanPrice } from "../../../lib/subscriptionPlans"
+import type { SubscriptionPlan } from "../../../types/subscription.types"
+
+type DeleteSubscriptionPlanDialogProps = {
+ plan: SubscriptionPlan | null
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onDeleted: (id: number) => void
+}
+
+export function DeleteSubscriptionPlanDialog({
+ plan,
+ open,
+ onOpenChange,
+ onDeleted,
+}: DeleteSubscriptionPlanDialogProps) {
+ const [deleting, setDeleting] = useState(false)
+
+ const handleOpenChange = (next: boolean) => {
+ if (!next && !deleting) onOpenChange(false)
+ }
+
+ const handleConfirm = async () => {
+ if (!plan) return
+
+ setDeleting(true)
+ try {
+ const res = await deleteSubscriptionPlan(plan.id)
+ toast.success(res.message || "Subscription plan deleted successfully")
+ onDeleted(plan.id)
+ onOpenChange(false)
+ } catch (e: unknown) {
+ console.error(e)
+ const msg =
+ (e as { response?: { data?: { message?: string } } })?.response?.data?.message ??
+ "Failed to delete subscription plan"
+ toast.error(msg)
+ } finally {
+ setDeleting(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/src/pages/settings/components/EditAppVersionDialog.tsx b/src/pages/settings/components/EditAppVersionDialog.tsx
new file mode 100644
index 0000000..13e0d59
--- /dev/null
+++ b/src/pages/settings/components/EditAppVersionDialog.tsx
@@ -0,0 +1,277 @@
+import { useEffect, useState } from "react"
+import { Save } from "lucide-react"
+import { toast } from "sonner"
+import { updateAppVersion } from "../../../api/app-versions.api"
+import { Badge } from "../../../components/ui/badge"
+import { Button } from "../../../components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "../../../components/ui/dialog"
+import { Input } from "../../../components/ui/input"
+import { Select } from "../../../components/ui/select"
+import { SpinnerIcon } from "../../../components/ui/spinner-icon"
+import { Textarea } from "../../../components/ui/textarea"
+import {
+ APP_UPDATE_TYPES,
+ APP_VERSION_STATUSES,
+ formatAppPlatform,
+ versionLabel,
+} from "../../../lib/appVersions"
+import type {
+ AppUpdateType,
+ AppVersion,
+ AppVersionStatus,
+ UpdateAppVersionPayload,
+} from "../../../types/app-version.types"
+
+interface EditDraft {
+ update_type: AppUpdateType
+ release_notes: string
+ store_url: string
+ min_supported_version_code: string
+ status: AppVersionStatus
+}
+
+function versionToDraft(version: AppVersion): EditDraft {
+ return {
+ update_type: version.update_type,
+ release_notes: version.release_notes,
+ store_url: version.store_url,
+ min_supported_version_code: String(version.min_supported_version_code),
+ status: version.status,
+ }
+}
+
+function draftToPayload(draft: EditDraft): UpdateAppVersionPayload | null {
+ const release_notes = draft.release_notes.trim()
+ const store_url = draft.store_url.trim()
+ const min_supported_version_code = Number(draft.min_supported_version_code)
+
+ if (!release_notes) return null
+ if (!store_url) return null
+ if (!Number.isFinite(min_supported_version_code) || min_supported_version_code < 0) return null
+
+ try {
+ new URL(store_url)
+ } catch {
+ return null
+ }
+
+ return {
+ update_type: draft.update_type,
+ release_notes,
+ store_url,
+ min_supported_version_code,
+ status: draft.status,
+ }
+}
+
+type EditAppVersionDialogProps = {
+ version: AppVersion | null
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onUpdated: (version: AppVersion) => void
+}
+
+export function EditAppVersionDialog({
+ version,
+ open,
+ onOpenChange,
+ onUpdated,
+}: EditAppVersionDialogProps) {
+ const [draft, setDraft] = useState(null)
+ const [saving, setSaving] = useState(false)
+
+ useEffect(() => {
+ if (open && version) {
+ setDraft(versionToDraft(version))
+ setSaving(false)
+ }
+ if (!open) {
+ setDraft(null)
+ setSaving(false)
+ }
+ }, [open, version])
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!version || !draft) return
+
+ const payload = draftToPayload(draft)
+ if (!payload) {
+ toast.error("Please fill in all required fields with valid values.")
+ return
+ }
+
+ setSaving(true)
+ try {
+ const res = await updateAppVersion(version.id, payload)
+ if (!res.data) {
+ toast.error("Version was updated but the response could not be read.")
+ return
+ }
+ toast.success(res.message || "App version updated successfully")
+ onUpdated({
+ ...res.data,
+ platform: res.data.platform || version.platform,
+ version_name: res.data.version_name || version.version_name,
+ version_code: res.data.version_code || version.version_code,
+ created_at: res.data.created_at || version.created_at,
+ })
+ onOpenChange(false)
+ } catch {
+ toast.error("Failed to update app version.")
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/src/pages/settings/components/EditSubscriptionPlanDialog.tsx b/src/pages/settings/components/EditSubscriptionPlanDialog.tsx
new file mode 100644
index 0000000..4963afe
--- /dev/null
+++ b/src/pages/settings/components/EditSubscriptionPlanDialog.tsx
@@ -0,0 +1,319 @@
+import { useEffect, useState } from "react"
+import { Save } from "lucide-react"
+import { toast } from "sonner"
+import { updateSubscriptionPlan } from "../../../api/subscription-plans.api"
+import { Badge } from "../../../components/ui/badge"
+import { Button } from "../../../components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "../../../components/ui/dialog"
+import { Input } from "../../../components/ui/input"
+import { Select } from "../../../components/ui/select"
+import { SpinnerIcon } from "../../../components/ui/spinner-icon"
+import { Textarea } from "../../../components/ui/textarea"
+import { cn } from "../../../lib/utils"
+import {
+ formatPlanCategory,
+ SUBSCRIPTION_CURRENCIES,
+ SUBSCRIPTION_DURATION_UNITS,
+} from "../../../lib/subscriptionPlans"
+import type {
+ SubscriptionPlan,
+ SubscriptionPlanDurationUnit,
+ UpdateSubscriptionPlanPayload,
+} from "../../../types/subscription.types"
+
+interface EditDraft {
+ name: string
+ description: string
+ duration_value: string
+ duration_unit: SubscriptionPlanDurationUnit
+ price: string
+ currency: string
+ is_active: boolean
+}
+
+function planToDraft(plan: SubscriptionPlan): EditDraft {
+ return {
+ name: plan.name,
+ description: plan.description,
+ duration_value: String(plan.duration_value),
+ duration_unit: plan.duration_unit,
+ price: String(plan.price),
+ currency: plan.currency,
+ is_active: plan.is_active,
+ }
+}
+
+function draftToPayload(draft: EditDraft): UpdateSubscriptionPlanPayload | null {
+ const name = draft.name.trim()
+ const description = draft.description.trim()
+ const duration_value = Number(draft.duration_value)
+ const price = Number(draft.price)
+
+ if (!name) return null
+ if (!description) return null
+ if (!Number.isFinite(duration_value) || duration_value < 1) return null
+ if (!Number.isFinite(price) || price < 0) return null
+
+ return {
+ name,
+ description,
+ duration_value,
+ duration_unit: draft.duration_unit,
+ price,
+ currency: draft.currency,
+ is_active: draft.is_active,
+ }
+}
+
+type EditSubscriptionPlanDialogProps = {
+ plan: SubscriptionPlan | null
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onUpdated: (plan: SubscriptionPlan) => void
+}
+
+export function EditSubscriptionPlanDialog({
+ plan,
+ open,
+ onOpenChange,
+ onUpdated,
+}: EditSubscriptionPlanDialogProps) {
+ const [draft, setDraft] = useState(null)
+ const [saving, setSaving] = useState(false)
+
+ useEffect(() => {
+ if (open && plan) {
+ setDraft(planToDraft(plan))
+ setSaving(false)
+ }
+ if (!open) {
+ setDraft(null)
+ setSaving(false)
+ }
+ }, [open, plan])
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!plan || !draft) return
+
+ const payload = draftToPayload(draft)
+ if (!payload) {
+ toast.error("Please fill in all required fields with valid values.")
+ return
+ }
+
+ setSaving(true)
+ try {
+ const res = await updateSubscriptionPlan(plan.id, payload)
+ if (!res.data) {
+ toast.error("Plan was updated but the response could not be read.")
+ return
+ }
+ toast.success(res.message || "Subscription plan updated successfully")
+ onUpdated({
+ ...res.data,
+ category: res.data.category || plan.category,
+ created_at: res.data.created_at || plan.created_at,
+ })
+ onOpenChange(false)
+ } catch {
+ toast.error("Failed to update subscription plan.")
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/src/pages/settings/components/ThemeModePreview.tsx b/src/pages/settings/components/ThemeModePreview.tsx
new file mode 100644
index 0000000..92b7cba
--- /dev/null
+++ b/src/pages/settings/components/ThemeModePreview.tsx
@@ -0,0 +1,75 @@
+import { cn } from "../../../lib/utils"
+import type { ResolvedTheme } from "../../../lib/theme"
+
+type ThemeModePreviewProps = {
+ variant: "light" | "dark" | "system"
+ systemResolved?: ResolvedTheme
+ className?: string
+}
+
+/** Mini UI mockup so theme options look distinct before applying. */
+export function ThemeModePreview({
+ variant,
+ systemResolved = "light",
+ className,
+}: ThemeModePreviewProps) {
+ if (variant === "system") {
+ return (
+
+
+
+ {systemResolved}
+
+
+ )
+ }
+
+ const isDark = variant === "dark"
+
+ return (
+
+ )
+}
diff --git a/src/pages/user-log/UserLogPage.tsx b/src/pages/user-log/UserLogPage.tsx
index 639a4f5..c56fa73 100644
--- a/src/pages/user-log/UserLogPage.tsx
+++ b/src/pages/user-log/UserLogPage.tsx
@@ -39,6 +39,7 @@ import { cn } from "../../lib/utils";
import { getActivityLogs, getActivityLogById } from "../../api/activity-logs.api";
import type { ActivityLog, ActivityLogFilters } from "../../types/activity-log.types";
import { SpinnerIcon } from "../../components/ui/spinner-icon";
+import { ActorHoverCard } from "./components/ActorHoverCard";
// ── Action type configuration ──────────────────────────────────────
const ACTION_TYPES = [
@@ -425,21 +426,26 @@ export function UserLogPage() {
-
-
-
-
-
-
- ID: {log.actor_id ?? "System"}
-
- {log.actor_role && (
-
- {formatRoleLabel(log.actor_role)}
+
+
+
+
+
+
+
+ ID: {log.actor_id ?? "System"}
- )}
+ {log.actor_role && (
+
+ {formatRoleLabel(log.actor_role)}
+
+ )}
+
-
+
diff --git a/src/pages/user-log/components/ActorHoverCard.tsx b/src/pages/user-log/components/ActorHoverCard.tsx
new file mode 100644
index 0000000..0c025dc
--- /dev/null
+++ b/src/pages/user-log/components/ActorHoverCard.tsx
@@ -0,0 +1,251 @@
+import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react"
+import { createPortal } from "react-dom"
+import { Mail, Shield, User } from "lucide-react"
+import { cn } from "../../../lib/utils"
+import {
+ fetchActorProfile,
+ formatActorDate,
+ type ActorProfile,
+} from "../../../lib/activityLogActor"
+import { SpinnerIcon } from "../../../components/ui/spinner-icon"
+
+const HOVER_DELAY_MS = 280
+const HIDE_DELAY_MS = 120
+
+type ActorHoverCardProps = {
+ actorId: number | null
+ actorRole: string | null
+ children: ReactNode
+}
+
+export function ActorHoverCard({ actorId, actorRole, children }: ActorHoverCardProps) {
+ const tooltipId = useId()
+ const triggerRef = useRef
(null)
+ const showTimerRef = useRef | null>(null)
+ const hideTimerRef = useRef | null>(null)
+ const requestRef = useRef(0)
+
+ const [open, setOpen] = useState(false)
+ const [visible, setVisible] = useState(false)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [profile, setProfile] = useState(null)
+ const [position, setPosition] = useState({ top: 0, left: 0 })
+
+ const updatePosition = useCallback(() => {
+ const rect = triggerRef.current?.getBoundingClientRect()
+ if (!rect) return
+ const cardWidth = 288
+ const gap = 10
+ let left = rect.right + gap
+ if (left + cardWidth > window.innerWidth - 12) {
+ left = rect.left - cardWidth - gap
+ }
+ setPosition({
+ top: rect.top + rect.height / 2,
+ left: Math.max(12, left),
+ })
+ }, [])
+
+ const clearTimers = useCallback(() => {
+ if (showTimerRef.current) {
+ clearTimeout(showTimerRef.current)
+ showTimerRef.current = null
+ }
+ if (hideTimerRef.current) {
+ clearTimeout(hideTimerRef.current)
+ hideTimerRef.current = null
+ }
+ }, [])
+
+ const close = useCallback(() => {
+ clearTimers()
+ setOpen(false)
+ setVisible(false)
+ }, [clearTimers])
+
+ const loadProfile = useCallback(async () => {
+ if (actorId == null) return
+ const requestId = ++requestRef.current
+ setLoading(true)
+ setError(null)
+ try {
+ const data = await fetchActorProfile(actorId, actorRole)
+ if (requestId !== requestRef.current) return
+ setProfile(data)
+ } catch {
+ if (requestId !== requestRef.current) return
+ setProfile(null)
+ setError("Could not load actor details")
+ } finally {
+ if (requestId === requestRef.current) setLoading(false)
+ }
+ }, [actorId, actorRole])
+
+ const handleEnter = useCallback(() => {
+ if (actorId == null) return
+ clearTimers()
+ hideTimerRef.current = setTimeout(() => {
+ updatePosition()
+ setOpen(true)
+ requestAnimationFrame(() => setVisible(true))
+ void loadProfile()
+ }, HOVER_DELAY_MS)
+ }, [actorId, clearTimers, loadProfile, updatePosition])
+
+ const handleLeave = useCallback(() => {
+ clearTimers()
+ showTimerRef.current = setTimeout(() => {
+ setVisible(false)
+ hideTimerRef.current = setTimeout(() => {
+ setOpen(false)
+ setProfile(null)
+ setError(null)
+ }, 180)
+ }, HIDE_DELAY_MS)
+ }, [clearTimers])
+
+ useEffect(() => {
+ if (!open) return
+ const onScrollOrResize = () => updatePosition()
+ window.addEventListener("scroll", onScrollOrResize, true)
+ window.addEventListener("resize", onScrollOrResize)
+ return () => {
+ window.removeEventListener("scroll", onScrollOrResize, true)
+ window.removeEventListener("resize", onScrollOrResize)
+ }
+ }, [open, updatePosition])
+
+ useEffect(() => () => clearTimers(), [clearTimers])
+
+ if (actorId == null) {
+ return <>{children}>
+ }
+
+ return (
+ <>
+
+ {children}
+
+
+ {open &&
+ createPortal(
+ ,
+ document.body,
+ )}
+ >
+ )
+}
+
+function ActorProfileContent({ profile }: { profile: ActorProfile }) {
+ const isTeam = profile.kind === "team"
+
+ return (
+
+
+
+ {isTeam ? : }
+
+
+
{profile.name}
+
+ {isTeam ? "Team member" : "Learner"} · #{profile.id}
+
+
+
+
+
+ } label="Email" value={profile.email} />
+
+
+
+ {profile.kind === "user" ? (
+ <>
+
+
+
+ >
+ ) : null}
+
+
+
+ )
+}
+
+function DetailRow({
+ icon,
+ label,
+ value,
+ capitalize,
+}: {
+ icon?: ReactNode
+ label: string
+ value: string
+ capitalize?: boolean
+}) {
+ return (
+
+ {icon ? (
+
{icon}
+ ) : (
+
+ )}
+
+
+ {label}
+
+
+ {value}
+
+
+
+ )
+}
diff --git a/src/types/analytics.types.ts b/src/types/analytics.types.ts
index cfdce8d..0e2bd8d 100644
--- a/src/types/analytics.types.ts
+++ b/src/types/analytics.types.ts
@@ -32,6 +32,7 @@ export interface DashboardUsers {
/** API field name (typo preserved to match backend). */
by_language_challange: LabelCount[]
by_knowledge_level?: LabelCount[]
+ by_country: LabelCount[]
by_region: LabelCount[]
registrations_last_30_days: DateCount[]
}
diff --git a/src/types/app-version.types.ts b/src/types/app-version.types.ts
new file mode 100644
index 0000000..fb5e202
--- /dev/null
+++ b/src/types/app-version.types.ts
@@ -0,0 +1,58 @@
+export type AppPlatform = "ANDROID" | "IOS" | string
+
+export type AppUpdateType = "FORCE" | "SOFT" | "OPTIONAL" | string
+
+export type AppVersionStatus = "ACTIVE" | "INACTIVE" | "DRAFT" | string
+
+export interface AppVersion {
+ id: number
+ platform: AppPlatform
+ version_name: string
+ version_code: number
+ update_type: AppUpdateType
+ release_notes: string
+ store_url: string
+ min_supported_version_code: number
+ status: AppVersionStatus
+ created_at: string
+}
+
+export interface CreateAppVersionPayload {
+ platform: AppPlatform
+ version_name: string
+ version_code: number
+ update_type: AppUpdateType
+ release_notes: string
+ store_url: string
+ min_supported_version_code: number
+ status: AppVersionStatus
+}
+
+export interface UpdateAppVersionPayload {
+ update_type: AppUpdateType
+ release_notes: string
+ store_url: string
+ min_supported_version_code: number
+ status: AppVersionStatus
+}
+
+export interface AppVersionsListData {
+ versions: AppVersion[]
+ total_count: number
+}
+
+export interface AppVersionsListResponse {
+ message?: string
+ data: AppVersionsListData
+ success?: boolean
+ status_code?: number
+ metadata?: unknown
+}
+
+export interface AppVersionMutationResponse {
+ message?: string
+ data: AppVersion
+ success?: boolean
+ status_code?: number
+ metadata?: unknown
+}
diff --git a/src/types/payment.types.ts b/src/types/payment.types.ts
new file mode 100644
index 0000000..751a0e5
--- /dev/null
+++ b/src/types/payment.types.ts
@@ -0,0 +1,61 @@
+export type PaymentStatus =
+ | "PENDING"
+ | "PROCESSING"
+ | "SUCCESS"
+ | "FAILED"
+ | "CANCELLED"
+ | "EXPIRED"
+ | string
+
+export type PaymentProvider = "CHAPA" | "ARIFPAY" | string
+
+export type PaymentMethod = PaymentProvider | string
+
+export type PaymentPlanCategory = "LEARN_ENGLISH" | "IELTS" | "DUOLINGO" | string
+
+export interface Payment {
+ id: number
+ user_id: number
+ plan_id: number
+ subscription_id: number
+ session_id: string
+ transaction_id: string
+ nonce: string
+ amount: number
+ currency: string
+ payment_method: PaymentMethod
+ status: PaymentStatus
+ payment_url: string
+ plan_name: string
+ plan_category: string
+ user_email: string
+ user_first_name: string
+ user_last_name: string
+ paid_at: string | null
+ expires_at: string | null
+ created_at: string
+ updated_at: string
+}
+
+export interface PaymentsListData {
+ payments: Payment[]
+ total_count: number
+ limit: number
+ offset: number
+}
+
+export interface PaymentsListResponse {
+ message?: string
+ data: PaymentsListData
+ success?: boolean
+ status_code?: number
+ metadata?: unknown
+}
+
+export interface GetPaymentsParams {
+ status?: PaymentStatus
+ provider?: PaymentProvider
+ plan_category?: PaymentPlanCategory
+ limit?: number
+ offset?: number
+}
diff --git a/src/types/subscription.types.ts b/src/types/subscription.types.ts
index 724a343..d63d866 100644
--- a/src/types/subscription.types.ts
+++ b/src/types/subscription.types.ts
@@ -1,7 +1,32 @@
export type SubscriptionPlanDurationUnit = "MONTH" | "YEAR" | "WEEK" | "DAY" | string
+export type SubscriptionPlanCategory = "LEARN_ENGLISH" | "EXAM_PREP" | "SKILLS" | string
+
export interface SubscriptionPlan {
id: number
+ name: string
+ description: string
+ category: SubscriptionPlanCategory
+ duration_value: number
+ duration_unit: SubscriptionPlanDurationUnit
+ price: number
+ currency: string
+ is_active: boolean
+ created_at: string
+}
+
+export interface CreateSubscriptionPlanPayload {
+ name: string
+ description: string
+ category: SubscriptionPlanCategory
+ duration_value: number
+ duration_unit: SubscriptionPlanDurationUnit
+ price: number
+ currency: string
+ is_active: boolean
+}
+
+export interface UpdateSubscriptionPlanPayload {
name: string
description: string
duration_value: number
@@ -9,7 +34,6 @@ export interface SubscriptionPlan {
price: number
currency: string
is_active: boolean
- created_at: string
}
export interface SubscriptionPlansListResponse {
@@ -19,3 +43,11 @@ export interface SubscriptionPlansListResponse {
status_code?: number
metadata?: unknown
}
+
+export interface SubscriptionPlanMutationResponse {
+ message?: string
+ data: SubscriptionPlan
+ success?: boolean
+ status_code?: number
+ metadata?: unknown
+}
diff --git a/src/types/team.types.ts b/src/types/team.types.ts
index 0d62e35..ac9822b 100644
--- a/src/types/team.types.ts
+++ b/src/types/team.types.ts
@@ -68,3 +68,16 @@ export interface GetTeamMemberResponse {
status_code: number
metadata: null
}
+
+/** POST /team/members/:id/change-password */
+export interface ChangeTeamMemberPasswordRequest {
+ current_password: string
+ new_password: string
+}
+
+export interface ChangeTeamMemberPasswordResponse {
+ message?: string
+ success?: boolean
+ status_code?: number
+ metadata?: unknown
+}
diff --git a/tailwind.config.js b/tailwind.config.js
index fdf2804..2d3429f 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -62,13 +62,13 @@ export default {
500: "#1DE9B6",
},
grayScale: {
- 50: "#FFFFFF",
- 100: "#F5F5F5",
- 200: "#E0E0E0",
- 300: "#BDBDBD",
- 400: "#9E9E9E",
- 500: "#757575",
- 600: "#616161",
+ 50: "var(--gs-50)",
+ 100: "var(--gs-100)",
+ 200: "var(--gs-200)",
+ 300: "var(--gs-300)",
+ 400: "var(--gs-400)",
+ 500: "var(--gs-500)",
+ 600: "var(--gs-600)",
},
},
borderRadius: {
@@ -77,7 +77,7 @@ export default {
sm: "calc(var(--radius) - 4px)",
},
boxShadow: {
- soft: "0 8px 24px rgba(0,0,0,0.06)",
+ soft: "var(--shadow-soft)",
},
},
},