feat(admin): payments, settings tabs, theme, and navigation refresh
Add admin payments with status, provider, and plan category filters. Introduce app versions and subscription plan management in settings, change-password security flow, and dark theme support. Reorganize sidebar, improve activity log actor details, analytics, and related UI polish. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e75420e756
commit
2c3f0da6f7
21
index.html
21
index.html
|
|
@ -5,6 +5,27 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>yimaru-admin</title>
|
||||
<script>
|
||||
(function () {
|
||||
var key = "yimaru-admin-theme";
|
||||
var stored = localStorage.getItem(key);
|
||||
var root = document.documentElement;
|
||||
var systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
var resolved =
|
||||
stored === "dark"
|
||||
? "dark"
|
||||
: stored === "system"
|
||||
? systemDark
|
||||
? "dark"
|
||||
: "light"
|
||||
: "light";
|
||||
root.classList.remove("dark");
|
||||
if (resolved === "dark") root.classList.add("dark");
|
||||
root.dataset.theme = resolved;
|
||||
root.dataset.themePreference = stored || "light";
|
||||
root.style.colorScheme = resolved;
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
33
src/App.tsx
33
src/App.tsx
|
|
@ -1,9 +1,29 @@
|
|||
import { useEffect } from 'react'
|
||||
import { Toaster } from 'sonner'
|
||||
import { AppRoutes } from './app/AppRoutes'
|
||||
import { useTheme } from './contexts/ThemeContext'
|
||||
|
||||
const SESSION_KEY = 'yimaru_session_active'
|
||||
|
||||
function AppToaster() {
|
||||
const { resolvedTheme } = useTheme()
|
||||
return (
|
||||
<Toaster
|
||||
position="top-center"
|
||||
theme={resolvedTheme}
|
||||
toastOptions={{
|
||||
className: 'font-sans',
|
||||
style: {
|
||||
padding: '14px 20px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
},
|
||||
}}
|
||||
richColors
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
useEffect(() => {
|
||||
if (!sessionStorage.getItem(SESSION_KEY)) {
|
||||
|
|
@ -18,18 +38,7 @@ export default function App() {
|
|||
return (
|
||||
<>
|
||||
<AppRoutes />
|
||||
<Toaster
|
||||
position="top-center"
|
||||
toastOptions={{
|
||||
className: 'font-sans',
|
||||
style: {
|
||||
padding: '14px 20px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
},
|
||||
}}
|
||||
richColors
|
||||
/>
|
||||
<AppToaster />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ function normalizeDashboardUsers(raw: unknown, root?: Record<string, unknown>):
|
|||
by_knowledge_level: asLabelCounts(
|
||||
pickField(u, "by_knowledge_level", "byKnowledgeLevel", "ByKnowledgeLevel"),
|
||||
),
|
||||
by_country: asLabelCounts(pickField(u, "by_country", "byCountry", "ByCountry")),
|
||||
by_region: asLabelCounts(pickField(u, "by_region", "byRegion", "ByRegion")),
|
||||
registrations_last_30_days: asDateCounts(
|
||||
pickField(
|
||||
|
|
|
|||
117
src/api/app-versions.api.ts
Normal file
117
src/api/app-versions.api.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import http from "./http"
|
||||
import type {
|
||||
AppVersion,
|
||||
AppVersionMutationResponse,
|
||||
AppVersionsListData,
|
||||
AppVersionsListResponse,
|
||||
CreateAppVersionPayload,
|
||||
UpdateAppVersionPayload,
|
||||
} from "../types/app-version.types"
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function normalizeAppVersion(raw: unknown): AppVersion | null {
|
||||
if (!isRecord(raw)) return null
|
||||
const id = Number(raw.id)
|
||||
if (!Number.isFinite(id)) return null
|
||||
|
||||
return {
|
||||
id,
|
||||
platform: String(raw.platform ?? ""),
|
||||
version_name: String(raw.version_name ?? ""),
|
||||
version_code: Number(raw.version_code ?? 0),
|
||||
update_type: String(raw.update_type ?? ""),
|
||||
release_notes: String(raw.release_notes ?? ""),
|
||||
store_url: String(raw.store_url ?? ""),
|
||||
min_supported_version_code: Number(raw.min_supported_version_code ?? 0),
|
||||
status: String(raw.status ?? ""),
|
||||
created_at: String(raw.created_at ?? ""),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAppVersionsList(body: unknown): AppVersionsListData {
|
||||
const empty: AppVersionsListData = { versions: [], total_count: 0 }
|
||||
|
||||
if (isRecord(body)) {
|
||||
const data = body.data
|
||||
if (isRecord(data) && Array.isArray(data.versions)) {
|
||||
const versions = data.versions
|
||||
.map(normalizeAppVersion)
|
||||
.filter((v): v is AppVersion => v !== null)
|
||||
const total_count = Number(data.total_count ?? versions.length)
|
||||
return { versions, total_count: Number.isFinite(total_count) ? total_count : versions.length }
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
const versions = data.map(normalizeAppVersion).filter((v): v is AppVersion => v !== null)
|
||||
return { versions, total_count: versions.length }
|
||||
}
|
||||
if (Array.isArray(body.versions)) {
|
||||
const versions = body.versions
|
||||
.map(normalizeAppVersion)
|
||||
.filter((v): v is AppVersion => v !== null)
|
||||
const total_count = Number(body.total_count ?? versions.length)
|
||||
return { versions, total_count: Number.isFinite(total_count) ? total_count : versions.length }
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(body)) {
|
||||
const versions = body.map(normalizeAppVersion).filter((v): v is AppVersion => v !== null)
|
||||
return { versions, total_count: versions.length }
|
||||
}
|
||||
|
||||
return empty
|
||||
}
|
||||
|
||||
export function parseAppVersionMutation(body: unknown): AppVersion | null {
|
||||
if (isRecord(body) && body.data != null) {
|
||||
return normalizeAppVersion(body.data)
|
||||
}
|
||||
return normalizeAppVersion(body)
|
||||
}
|
||||
|
||||
export type GetAppVersionsParams = {
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export const getAppVersions = (params: GetAppVersionsParams = {}) => {
|
||||
const limit = params.limit ?? 20
|
||||
const offset = params.offset ?? 0
|
||||
return http
|
||||
.get<AppVersionsListResponse>("/admin/app-versions", { params: { limit, offset } })
|
||||
.then((res) => {
|
||||
const parsed = parseAppVersionsList(res.data)
|
||||
return {
|
||||
...res,
|
||||
data: parsed,
|
||||
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function mutationResult(res: { data: unknown }) {
|
||||
const version = parseAppVersionMutation(res.data)
|
||||
return {
|
||||
...res,
|
||||
data: version,
|
||||
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const createAppVersion = (payload: CreateAppVersionPayload) =>
|
||||
http
|
||||
.post<AppVersionMutationResponse>("/admin/app-versions", payload)
|
||||
.then(mutationResult)
|
||||
|
||||
export const updateAppVersion = (id: number, payload: UpdateAppVersionPayload) =>
|
||||
http
|
||||
.put<AppVersionMutationResponse>(`/admin/app-versions/${id}`, payload)
|
||||
.then(mutationResult)
|
||||
|
||||
export const deleteAppVersion = (id: number) =>
|
||||
http.delete<{ message?: string }>(`/admin/app-versions/${id}`).then((res) => ({
|
||||
...res,
|
||||
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
|
||||
}))
|
||||
98
src/api/payments.api.ts
Normal file
98
src/api/payments.api.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import http from "./http"
|
||||
import type { GetPaymentsParams, Payment, PaymentsListData, PaymentsListResponse } from "../types/payment.types"
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function normalizePayment(raw: unknown): Payment | null {
|
||||
if (!isRecord(raw)) return null
|
||||
const id = Number(raw.id)
|
||||
if (!Number.isFinite(id)) return null
|
||||
|
||||
const paid_at = raw.paid_at
|
||||
const expires_at = raw.expires_at
|
||||
|
||||
return {
|
||||
id,
|
||||
user_id: Number(raw.user_id ?? 0),
|
||||
plan_id: Number(raw.plan_id ?? 0),
|
||||
subscription_id: Number(raw.subscription_id ?? 0),
|
||||
session_id: String(raw.session_id ?? ""),
|
||||
transaction_id: String(raw.transaction_id ?? ""),
|
||||
nonce: String(raw.nonce ?? ""),
|
||||
amount: Number(raw.amount ?? 0),
|
||||
currency: String(raw.currency ?? "ETB"),
|
||||
payment_method: String(raw.payment_method ?? ""),
|
||||
status: String(raw.status ?? ""),
|
||||
payment_url: String(raw.payment_url ?? ""),
|
||||
plan_name: String(raw.plan_name ?? ""),
|
||||
plan_category: String(raw.plan_category ?? ""),
|
||||
user_email: String(raw.user_email ?? ""),
|
||||
user_first_name: String(raw.user_first_name ?? ""),
|
||||
user_last_name: String(raw.user_last_name ?? ""),
|
||||
paid_at: paid_at == null || paid_at === "" ? null : String(paid_at),
|
||||
expires_at: expires_at == null || expires_at === "" ? null : String(expires_at),
|
||||
created_at: String(raw.created_at ?? ""),
|
||||
updated_at: String(raw.updated_at ?? ""),
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePaymentsList(body: unknown): PaymentsListData {
|
||||
const empty: PaymentsListData = {
|
||||
payments: [],
|
||||
total_count: 0,
|
||||
limit: 0,
|
||||
offset: 0,
|
||||
}
|
||||
|
||||
if (isRecord(body)) {
|
||||
const data = body.data
|
||||
if (isRecord(data) && Array.isArray(data.payments)) {
|
||||
const payments = data.payments
|
||||
.map(normalizePayment)
|
||||
.filter((p): p is Payment => p !== null)
|
||||
const total_count = Number(data.total_count ?? payments.length)
|
||||
const limit = Number(data.limit ?? payments.length)
|
||||
const offset = Number(data.offset ?? 0)
|
||||
return {
|
||||
payments,
|
||||
total_count: Number.isFinite(total_count) ? total_count : payments.length,
|
||||
limit: Number.isFinite(limit) ? limit : payments.length,
|
||||
offset: Number.isFinite(offset) ? offset : 0,
|
||||
}
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
const payments = data.map(normalizePayment).filter((p): p is Payment => p !== null)
|
||||
return { payments, total_count: payments.length, limit: payments.length, offset: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(body)) {
|
||||
const payments = body.map(normalizePayment).filter((p): p is Payment => p !== null)
|
||||
return { payments, total_count: payments.length, limit: payments.length, offset: 0 }
|
||||
}
|
||||
|
||||
return empty
|
||||
}
|
||||
|
||||
function buildQueryParams(params: GetPaymentsParams): Record<string, string | number> {
|
||||
const query: Record<string, string | number> = {
|
||||
limit: Math.min(100, Math.max(1, params.limit ?? 20)),
|
||||
offset: Math.max(0, params.offset ?? 0),
|
||||
}
|
||||
if (params.status?.trim()) query.status = params.status.trim()
|
||||
if (params.provider?.trim()) query.provider = params.provider.trim()
|
||||
if (params.plan_category?.trim()) query.plan_category = params.plan_category.trim()
|
||||
return query
|
||||
}
|
||||
|
||||
export const getPayments = (params: GetPaymentsParams = {}) =>
|
||||
http.get<PaymentsListResponse>("/admin/payments", { params: buildQueryParams(params) }).then((res) => {
|
||||
const parsed = parsePaymentsList(res.data)
|
||||
return {
|
||||
...res,
|
||||
data: parsed,
|
||||
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
|
||||
}
|
||||
})
|
||||
|
|
@ -1,11 +1,84 @@
|
|||
import http from "./http"
|
||||
import type { SubscriptionPlansListResponse, SubscriptionPlan } from "../types/subscription.types"
|
||||
import type {
|
||||
CreateSubscriptionPlanPayload,
|
||||
SubscriptionPlan,
|
||||
SubscriptionPlanMutationResponse,
|
||||
SubscriptionPlansListResponse,
|
||||
UpdateSubscriptionPlanPayload,
|
||||
} from "../types/subscription.types"
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function normalizeSubscriptionPlan(raw: unknown): SubscriptionPlan | null {
|
||||
if (!isRecord(raw)) return null
|
||||
const id = Number(raw.id)
|
||||
if (!Number.isFinite(id)) return null
|
||||
|
||||
return {
|
||||
id,
|
||||
name: String(raw.name ?? ""),
|
||||
description: String(raw.description ?? ""),
|
||||
category: String(raw.category ?? ""),
|
||||
duration_value: Number(raw.duration_value ?? 0),
|
||||
duration_unit: String(raw.duration_unit ?? "MONTH"),
|
||||
price: Number(raw.price ?? 0),
|
||||
currency: String(raw.currency ?? "ETB"),
|
||||
is_active: Boolean(raw.is_active ?? true),
|
||||
created_at: String(raw.created_at ?? ""),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseSubscriptionPlansList(body: unknown): SubscriptionPlan[] {
|
||||
if (Array.isArray(body)) {
|
||||
return body.map(normalizeSubscriptionPlan).filter((p): p is SubscriptionPlan => p !== null)
|
||||
}
|
||||
if (isRecord(body) && Array.isArray(body.data)) {
|
||||
return body.data
|
||||
.map(normalizeSubscriptionPlan)
|
||||
.filter((p): p is SubscriptionPlan => p !== null)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function parseSubscriptionPlanMutation(body: unknown): SubscriptionPlan | null {
|
||||
if (isRecord(body) && body.data != null) {
|
||||
return normalizeSubscriptionPlan(body.data)
|
||||
}
|
||||
return normalizeSubscriptionPlan(body)
|
||||
}
|
||||
|
||||
export const getSubscriptionPlans = () =>
|
||||
http.get<SubscriptionPlansListResponse>("/subscription-plans").then((res) => ({
|
||||
http.get<SubscriptionPlansListResponse | SubscriptionPlan[]>("/subscription-plans").then((res) => {
|
||||
const plans = parseSubscriptionPlansList(res.data)
|
||||
return {
|
||||
...res,
|
||||
data: {
|
||||
...res.data,
|
||||
data: Array.isArray(res.data?.data) ? res.data.data : ([] as SubscriptionPlan[]),
|
||||
},
|
||||
data: plans,
|
||||
}
|
||||
})
|
||||
|
||||
function mutationResult(res: { data: unknown }) {
|
||||
const plan = parseSubscriptionPlanMutation(res.data)
|
||||
return {
|
||||
...res,
|
||||
data: plan,
|
||||
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const createSubscriptionPlan = (payload: CreateSubscriptionPlanPayload) =>
|
||||
http
|
||||
.post<SubscriptionPlanMutationResponse | SubscriptionPlan>("/subscription-plans", payload)
|
||||
.then(mutationResult)
|
||||
|
||||
export const updateSubscriptionPlan = (id: number, payload: UpdateSubscriptionPlanPayload) =>
|
||||
http
|
||||
.put<SubscriptionPlanMutationResponse | SubscriptionPlan>(`/subscription-plans/${id}`, payload)
|
||||
.then(mutationResult)
|
||||
|
||||
export const deleteSubscriptionPlan = (id: number) =>
|
||||
http.delete<{ message?: string }>(`/subscription-plans/${id}`).then((res) => ({
|
||||
...res,
|
||||
message: isRecord(res.data) ? String(res.data.message ?? "") : undefined,
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import type {
|
|||
VerifyInvitationResponse,
|
||||
} from "../types/teamInvitation.types"
|
||||
import type {
|
||||
ChangeTeamMemberPasswordRequest,
|
||||
ChangeTeamMemberPasswordResponse,
|
||||
GetTeamMembersResponse,
|
||||
GetTeamMemberResponse,
|
||||
CreateTeamMemberRequest,
|
||||
|
|
@ -33,6 +35,10 @@ export const updateTeamMemberStatus = (id: number, status: string) =>
|
|||
export const updateTeamMember = (id: number, data: UpdateTeamMemberRequest) =>
|
||||
http.put(`/team/members/${id}`, data)
|
||||
|
||||
/** POST /team/members/:id/change-password — change the signed-in member's password. */
|
||||
export const changeTeamMemberPassword = (id: number, data: ChangeTeamMemberPasswordRequest) =>
|
||||
http.post<ChangeTeamMemberPasswordResponse>(`/team/members/${id}/change-password`, data)
|
||||
|
||||
/** POST /team/members/invite — send invitation email (permission: team.members.invite). */
|
||||
export const inviteTeamMember = (data: InviteTeamMemberRequest) =>
|
||||
http.post<InviteTeamMemberResponse>("/team/members/invite", data)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import { HumanLanguageHierarchyPage } from "../pages/content-management/HumanLan
|
|||
import { HumanLanguageSubModulePage } from "../pages/content-management/HumanLanguageSubModulePage";
|
||||
import { UserLogPage } from "../pages/user-log/UserLogPage";
|
||||
import { IssuesPage } from "../pages/issues/IssuesPage";
|
||||
import { PaymentsPage } from "../pages/payments/PaymentsPage";
|
||||
import { ProfilePage } from "../pages/ProfilePage";
|
||||
import { SettingsPage } from "../pages/SettingsPage";
|
||||
import { TeamManagementPage } from "../pages/team/TeamManagementPage";
|
||||
|
|
@ -255,6 +256,7 @@ export function AppRoutes() {
|
|||
path="/notifications/create"
|
||||
element={<CreateNotificationPage />}
|
||||
/>
|
||||
<Route path="/payments" element={<PaymentsPage />} />
|
||||
<Route path="/user-log" element={<UserLogPage />} />
|
||||
<Route path="/issues" element={<IssuesPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
|
|
|
|||
|
|
@ -6,12 +6,11 @@ import {
|
|||
ChevronRight,
|
||||
CircleAlert,
|
||||
ClipboardList,
|
||||
CreditCard,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Shield,
|
||||
UserCircle2,
|
||||
Users,
|
||||
Users2,
|
||||
Settings,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
|
@ -33,32 +32,71 @@ type NavGroupItem = {
|
|||
kind: "group";
|
||||
label: string;
|
||||
basePath: string;
|
||||
activePaths?: string[];
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
children: { label: string; to: string; end?: boolean }[];
|
||||
};
|
||||
|
||||
type NavEntry = NavLinkItem | NavGroupItem;
|
||||
type NavSectionItem = {
|
||||
kind: "section";
|
||||
label: string;
|
||||
};
|
||||
|
||||
type NavEntry = NavLinkItem | NavGroupItem | NavSectionItem;
|
||||
|
||||
const navEntries: NavEntry[] = [
|
||||
{ kind: "section", label: "Overview" },
|
||||
{ kind: "link", label: "Dashboard", to: "/dashboard", icon: LayoutDashboard },
|
||||
{ kind: "link", label: "User Management", to: "/users", icon: Users },
|
||||
{ kind: "link", label: "Role Management", to: "/roles", icon: Shield },
|
||||
{ kind: "link", label: "Content Management", to: "/content", icon: BookOpen },
|
||||
{ kind: "link", label: "New Content", to: "/new-content", icon: BookOpen },
|
||||
{ kind: "link", label: "Analytics", to: "/analytics", icon: BarChart3 },
|
||||
|
||||
{ kind: "section", label: "People" },
|
||||
{
|
||||
kind: "group",
|
||||
label: "Users & access",
|
||||
basePath: "/users",
|
||||
activePaths: ["/users", "/roles", "/team"],
|
||||
icon: Users,
|
||||
children: [
|
||||
{ label: "All users", to: "/users/list" },
|
||||
{ label: "Roles", to: "/roles" },
|
||||
{ label: "Team members", to: "/team" },
|
||||
],
|
||||
},
|
||||
|
||||
{ kind: "section", label: "Learning content" },
|
||||
{
|
||||
kind: "group",
|
||||
label: "Content",
|
||||
basePath: "/content",
|
||||
activePaths: ["/content", "/new-content"],
|
||||
icon: BookOpen,
|
||||
children: [
|
||||
{ label: "Manage practices", to: "/content", end: true },
|
||||
{ label: "New content", to: "/new-content", end: true },
|
||||
{ label: "Reorder structure", to: "/new-content/reorder" },
|
||||
{ label: "Question types", to: "/new-content/question-types" },
|
||||
],
|
||||
},
|
||||
|
||||
{ kind: "section", label: "Communications" },
|
||||
{
|
||||
kind: "group",
|
||||
label: "Notifications",
|
||||
basePath: "/notifications",
|
||||
icon: Bell,
|
||||
children: [
|
||||
{ label: "My Notifications", to: "/notifications", end: true },
|
||||
{ label: "Email Templates", to: "/notifications/email-templates" },
|
||||
{ label: "Inbox", to: "/notifications", end: true },
|
||||
{ label: "Email templates", to: "/notifications/email-templates" },
|
||||
{ label: "Send notification", to: "/notifications/create" },
|
||||
],
|
||||
},
|
||||
{ kind: "link", label: "User Log", to: "/user-log", icon: ClipboardList },
|
||||
{ kind: "link", label: "Issue Reports", to: "/issues", icon: CircleAlert },
|
||||
{ kind: "link", label: "Analytics", to: "/analytics", icon: BarChart3 },
|
||||
{ kind: "link", label: "Team Management", to: "/team", icon: Users2 },
|
||||
|
||||
{ kind: "section", label: "Operations" },
|
||||
{ kind: "link", label: "Payments", to: "/payments", icon: CreditCard },
|
||||
{ kind: "link", label: "User activity log", to: "/user-log", icon: ClipboardList },
|
||||
{ kind: "link", label: "Issue reports", to: "/issues", icon: CircleAlert },
|
||||
|
||||
{ kind: "section", label: "Account" },
|
||||
{ kind: "link", label: "Profile", to: "/profile", icon: UserCircle2 },
|
||||
{ kind: "link", label: "Settings", to: "/settings", icon: Settings },
|
||||
];
|
||||
|
|
@ -162,19 +200,50 @@ export function Sidebar({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="mt-6 flex-1 space-y-1 overflow-y-auto">
|
||||
{navEntries.map((entry) => {
|
||||
<nav className="mt-6 flex-1 space-y-0.5 overflow-y-auto">
|
||||
{navEntries.map((entry, index) => {
|
||||
if (entry.kind === "section") {
|
||||
if (isCollapsed) {
|
||||
return index > 0 ? (
|
||||
<div
|
||||
key={`section-gap-${entry.label}`}
|
||||
className="mx-auto my-2 h-px w-6 bg-grayScale-200"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
return (
|
||||
<p
|
||||
key={`section-${entry.label}`}
|
||||
className={cn(
|
||||
"mb-1 px-3 pt-3 text-[10px] font-bold uppercase tracking-wider text-grayScale-400",
|
||||
index === 0 && "pt-0",
|
||||
)}
|
||||
>
|
||||
{entry.label}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.kind === "group") {
|
||||
const isNotifications = entry.basePath === "/notifications";
|
||||
return (
|
||||
<SidebarNavGroup
|
||||
key={entry.basePath}
|
||||
label={entry.label}
|
||||
icon={entry.icon}
|
||||
basePath={entry.basePath}
|
||||
activePaths={entry.activePaths}
|
||||
children={entry.children}
|
||||
isCollapsed={isCollapsed}
|
||||
onNavigate={onClose}
|
||||
trailing={!isCollapsed ? unreadBadge : collapsedUnreadDot}
|
||||
trailing={
|
||||
isNotifications
|
||||
? !isCollapsed
|
||||
? unreadBadge
|
||||
: collapsedUnreadDot
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ type SidebarNavGroupProps = {
|
|||
label: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
basePath: string;
|
||||
/** When set, any matching prefix marks the group active (e.g. `/content` and `/new-content`). */
|
||||
activePaths?: string[];
|
||||
children: SidebarNavChild[];
|
||||
isCollapsed: boolean;
|
||||
onNavigate?: () => void;
|
||||
|
|
@ -23,6 +25,7 @@ export function SidebarNavGroup({
|
|||
label,
|
||||
icon: Icon,
|
||||
basePath,
|
||||
activePaths,
|
||||
children,
|
||||
isCollapsed,
|
||||
onNavigate,
|
||||
|
|
@ -30,7 +33,8 @@ export function SidebarNavGroup({
|
|||
}: SidebarNavGroupProps) {
|
||||
const location = useLocation();
|
||||
const panelId = useId();
|
||||
const isSectionActive = location.pathname.startsWith(basePath);
|
||||
const paths = activePaths?.length ? activePaths : [basePath];
|
||||
const isSectionActive = paths.some((path) => location.pathname.startsWith(path));
|
||||
const [expanded, setExpanded] = useState(isSectionActive);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ const buttonVariants = cva(
|
|||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-brand-600",
|
||||
default: "bg-primary text-white hover:bg-brand-600 hover:text-white",
|
||||
brand: "bg-brand-500 text-white hover:bg-brand-600 hover:text-white",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
outline: "border bg-background hover:bg-grayScale-100",
|
||||
ghost: "hover:bg-grayScale-100",
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const DialogContent = React.forwardRef<
|
|||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 text-card-foreground shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-[6px] border bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-grayScale-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-10 w-full rounded-[6px] border border-input bg-grayScale-50 px-3 py-2 text-sm text-foreground ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
|||
<div className="relative">
|
||||
<select
|
||||
className={cn(
|
||||
"flex h-11 w-full appearance-none rounded-xl border border-grayScale-200 bg-white px-3 py-2 pr-8 text-sm text-grayScale-600 shadow-sm ring-offset-background transition hover:bg-grayScale-50 focus-visible:border-brand-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-200 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-11 w-full appearance-none rounded-xl border border-input bg-grayScale-50 px-3 py-2 pr-8 text-sm text-foreground shadow-sm ring-offset-background transition hover:bg-grayScale-100 focus-visible:border-brand-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-200 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-lg border bg-white px-3 py-2 text-sm ring-offset-background placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex min-h-[80px] w-full rounded-lg border border-input bg-grayScale-50 px-3 py-2 text-sm text-foreground ring-offset-background placeholder:text-grayScale-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
|||
76
src/contexts/ThemeContext.tsx
Normal file
76
src/contexts/ThemeContext.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import {
|
||||
applyTheme,
|
||||
getStoredTheme,
|
||||
getSystemTheme,
|
||||
resolveTheme,
|
||||
THEME_STORAGE_KEY,
|
||||
watchSystemTheme,
|
||||
type ResolvedTheme,
|
||||
type ThemeMode,
|
||||
} from "../lib/theme"
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: ThemeMode
|
||||
resolvedTheme: ResolvedTheme
|
||||
systemTheme: ResolvedTheme
|
||||
setTheme: (mode: ThemeMode) => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<ThemeMode>(() => getStoredTheme())
|
||||
const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(() =>
|
||||
resolveTheme(getStoredTheme()),
|
||||
)
|
||||
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>(() => getSystemTheme())
|
||||
|
||||
const setTheme = useCallback((mode: ThemeMode) => {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, mode)
|
||||
setThemeState(mode)
|
||||
setResolvedTheme(applyTheme(mode))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setResolvedTheme(applyTheme(theme))
|
||||
}, [theme])
|
||||
|
||||
useEffect(() => {
|
||||
return watchSystemTheme((next) => {
|
||||
setSystemTheme(next)
|
||||
if (theme === "system") {
|
||||
setResolvedTheme(applyTheme("system"))
|
||||
}
|
||||
})
|
||||
}, [theme])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ theme, resolvedTheme, systemTheme, setTheme }),
|
||||
[theme, resolvedTheme, systemTheme, setTheme],
|
||||
)
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const ctx = useContext(ThemeContext)
|
||||
if (!ctx) {
|
||||
throw new Error("useTheme must be used within ThemeProvider")
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function useThemeOptional(): ThemeContextValue | null {
|
||||
return useContext(ThemeContext)
|
||||
}
|
||||
|
||||
export { getSystemTheme }
|
||||
109
src/index.css
109
src/index.css
|
|
@ -5,7 +5,17 @@
|
|||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
html {
|
||||
color-scheme: light;
|
||||
--gs-50: #ffffff;
|
||||
--gs-100: #f5f5f5;
|
||||
--gs-200: #e0e0e0;
|
||||
--gs-300: #bdbdbd;
|
||||
--gs-400: #9e9e9e;
|
||||
--gs-500: #757575;
|
||||
--gs-600: #616161;
|
||||
--shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.06);
|
||||
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
|
||||
|
|
@ -38,6 +48,46 @@
|
|||
--radius: 14px;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
--gs-50: #1c1c24;
|
||||
--gs-100: #12121a;
|
||||
--gs-200: #2e2e3a;
|
||||
--gs-300: #454552;
|
||||
--gs-400: #9a9aaa;
|
||||
--gs-500: #b8b8c6;
|
||||
--gs-600: #ececf2;
|
||||
--shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
|
||||
--background: 240 10% 7%;
|
||||
--foreground: 210 20% 96%;
|
||||
|
||||
--card: 240 8% 12%;
|
||||
--card-foreground: 210 20% 96%;
|
||||
|
||||
--popover: 240 8% 12%;
|
||||
--popover-foreground: 210 20% 96%;
|
||||
|
||||
--primary: 312 59% 45%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
--secondary: 240 6% 18%;
|
||||
--secondary-foreground: 210 20% 96%;
|
||||
|
||||
--muted: 240 6% 18%;
|
||||
--muted-foreground: 240 5% 65%;
|
||||
|
||||
--accent: 240 6% 18%;
|
||||
--accent-foreground: 210 20% 96%;
|
||||
|
||||
--destructive: 0 62% 50%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--border: 240 6% 22%;
|
||||
--input: 240 6% 22%;
|
||||
--ring: 312 59% 50%;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
|
@ -49,6 +99,61 @@
|
|||
}
|
||||
|
||||
body {
|
||||
@apply bg-grayScale-100 text-foreground font-sans antialiased;
|
||||
@apply bg-grayScale-100 text-foreground font-sans antialiased transition-colors duration-200;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/*
|
||||
* Brand scale uses heavy purple for 50/500/600 — enforce high-contrast white
|
||||
* foreground on solid brand fills (including opacity modifiers).
|
||||
*/
|
||||
:is(
|
||||
.bg-brand-50,
|
||||
.bg-brand-500,
|
||||
.bg-brand-600,
|
||||
[class*="bg-brand-500/"],
|
||||
[class*="bg-brand-600/"]
|
||||
) {
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
:is(
|
||||
.bg-brand-50,
|
||||
.bg-brand-500,
|
||||
.bg-brand-600,
|
||||
[class*="bg-brand-500/"],
|
||||
[class*="bg-brand-600/"]
|
||||
)
|
||||
:is(.text-brand-500, .text-brand-600, .text-brand-700, .text-brand-800) {
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
:is(
|
||||
.bg-brand-50,
|
||||
.bg-brand-500,
|
||||
.bg-brand-600,
|
||||
[class*="bg-brand-500/"],
|
||||
[class*="bg-brand-600/"]
|
||||
)
|
||||
svg:not([class*="text-"]) {
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
.hover\:bg-brand-50:hover,
|
||||
.hover\:bg-brand-500:hover,
|
||||
.hover\:bg-brand-600:hover {
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
.hover\:bg-brand-50:hover svg,
|
||||
.hover\:bg-brand-500:hover svg,
|
||||
.hover\:bg-brand-600:hover svg {
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
/* Map legacy light-only surfaces to theme tokens in dark mode */
|
||||
html.dark .bg-white {
|
||||
background-color: var(--gs-50);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
143
src/lib/activityLogActor.ts
Normal file
143
src/lib/activityLogActor.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { getTeamMemberById } from "../api/team.api"
|
||||
import { getUserById } from "../api/users.api"
|
||||
import { TEAM_ROLE_OPTIONS, formatTeamRoleLabel } from "./teamRoles"
|
||||
import type { TeamMember } from "../types/team.types"
|
||||
import type { UserProfileData } from "../types/user.types"
|
||||
|
||||
const TEAM_ROLE_VALUES = new Set(
|
||||
TEAM_ROLE_OPTIONS.map((o) => o.value.toUpperCase()),
|
||||
)
|
||||
|
||||
const APP_USER_ROLES = new Set(["STUDENT", "LEARNER", "USER", "SUBSCRIBER"])
|
||||
|
||||
export type ActorProfileKind = "team" | "user"
|
||||
|
||||
export type ActorProfile =
|
||||
| {
|
||||
kind: "team"
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
roleLabel: string
|
||||
status: string
|
||||
emailVerified: boolean
|
||||
createdAt: string
|
||||
}
|
||||
| {
|
||||
kind: "user"
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
roleLabel: string
|
||||
status: string
|
||||
emailVerified: boolean
|
||||
country: string
|
||||
region: string
|
||||
lastLogin: string | null
|
||||
subscriptionStatus: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
function normalizeRole(role: string): string {
|
||||
return role.trim().toUpperCase().replace(/[\s-]+/g, "_")
|
||||
}
|
||||
|
||||
/** Choose API from activity log `actor_role` (team_role vs learner role). */
|
||||
export function resolveActorKind(actorRole: string | null | undefined): ActorProfileKind | null {
|
||||
if (!actorRole?.trim()) return null
|
||||
const upper = normalizeRole(actorRole)
|
||||
if (TEAM_ROLE_VALUES.has(upper)) return "team"
|
||||
if (APP_USER_ROLES.has(upper)) return "user"
|
||||
return null
|
||||
}
|
||||
|
||||
function teamMemberToProfile(member: TeamMember): ActorProfile {
|
||||
return {
|
||||
kind: "team",
|
||||
id: member.id,
|
||||
name: [member.first_name, member.last_name].filter(Boolean).join(" ") || "—",
|
||||
email: member.email || "—",
|
||||
roleLabel: formatTeamRoleLabel(member.team_role),
|
||||
status: member.status || "—",
|
||||
emailVerified: Boolean(member.email_verified),
|
||||
createdAt: member.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
function userToProfile(user: UserProfileData): ActorProfile {
|
||||
return {
|
||||
kind: "user",
|
||||
id: user.id,
|
||||
name: [user.first_name, user.last_name].filter(Boolean).join(" ") || "—",
|
||||
email: user.email || "—",
|
||||
roleLabel: user.role
|
||||
? user.role.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
: "—",
|
||||
status: user.status || "—",
|
||||
emailVerified: Boolean(user.email_verified),
|
||||
country: user.country || "—",
|
||||
region: user.region || "—",
|
||||
lastLogin: user.last_login,
|
||||
subscriptionStatus: user.subscription_status?.trim() || "—",
|
||||
createdAt: user.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
const profileCache = new Map<string, ActorProfile | "error">()
|
||||
|
||||
function cacheKey(actorId: number, kind: ActorProfileKind): string {
|
||||
return `${kind}:${actorId}`
|
||||
}
|
||||
|
||||
async function fetchTeamProfile(actorId: number): Promise<ActorProfile> {
|
||||
const res = await getTeamMemberById(actorId)
|
||||
return teamMemberToProfile(res.data.data)
|
||||
}
|
||||
|
||||
async function fetchUserProfile(actorId: number): Promise<ActorProfile> {
|
||||
const res = await getUserById(actorId)
|
||||
return userToProfile(res.data.data)
|
||||
}
|
||||
|
||||
export async function fetchActorProfile(
|
||||
actorId: number,
|
||||
actorRole: string | null | undefined,
|
||||
): Promise<ActorProfile> {
|
||||
const kind = resolveActorKind(actorRole)
|
||||
|
||||
const load = async (target: ActorProfileKind): Promise<ActorProfile> => {
|
||||
const key = cacheKey(actorId, target)
|
||||
const cached = profileCache.get(key)
|
||||
if (cached && cached !== "error") return cached
|
||||
if (cached === "error") throw new Error("Actor not found")
|
||||
|
||||
try {
|
||||
const profile =
|
||||
target === "team" ? await fetchTeamProfile(actorId) : await fetchUserProfile(actorId)
|
||||
profileCache.set(key, profile)
|
||||
return profile
|
||||
} catch (e) {
|
||||
profileCache.set(key, "error")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === "team") return load("team")
|
||||
if (kind === "user") return load("user")
|
||||
|
||||
try {
|
||||
return await load("team")
|
||||
} catch {
|
||||
return load("user")
|
||||
}
|
||||
}
|
||||
|
||||
export function formatActorDate(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
79
src/lib/appVersions.ts
Normal file
79
src/lib/appVersions.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import type {
|
||||
AppPlatform,
|
||||
AppUpdateType,
|
||||
AppVersion,
|
||||
AppVersionStatus,
|
||||
} from "../types/app-version.types"
|
||||
|
||||
export const APP_PLATFORMS: { value: AppPlatform; label: string }[] = [
|
||||
{ value: "ANDROID", label: "Android" },
|
||||
{ value: "IOS", label: "iOS" },
|
||||
]
|
||||
|
||||
export const APP_UPDATE_TYPES: { value: AppUpdateType; label: string; description: string }[] = [
|
||||
{
|
||||
value: "FORCE",
|
||||
label: "Force update",
|
||||
description: "Users must update before continuing",
|
||||
},
|
||||
{
|
||||
value: "SOFT",
|
||||
label: "Soft update",
|
||||
description: "Recommended update; users can dismiss",
|
||||
},
|
||||
{
|
||||
value: "OPTIONAL",
|
||||
label: "Optional",
|
||||
description: "Informational prompt only",
|
||||
},
|
||||
]
|
||||
|
||||
export const APP_VERSION_STATUSES: { value: AppVersionStatus; label: string }[] = [
|
||||
{ value: "ACTIVE", label: "Active" },
|
||||
{ value: "INACTIVE", label: "Inactive" },
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
]
|
||||
|
||||
export const DEFAULT_STORE_URLS: Record<string, string> = {
|
||||
ANDROID: "https://play.google.com/store/apps/details?id=com.yimaru.app",
|
||||
IOS: "https://apps.apple.com/app/id000000000",
|
||||
}
|
||||
|
||||
export function formatAppPlatform(platform: string): string {
|
||||
const match = APP_PLATFORMS.find((p) => p.value === platform)
|
||||
if (match) return match.label
|
||||
return platform.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
export function formatUpdateType(updateType: string): string {
|
||||
const match = APP_UPDATE_TYPES.find((t) => t.value === updateType)
|
||||
if (match) return match.label
|
||||
return updateType.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
export function formatVersionStatus(status: string): string {
|
||||
const match = APP_VERSION_STATUSES.find((s) => s.value === status)
|
||||
if (match) return match.label
|
||||
return status.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
export function formatAppVersionCreatedAt(raw: string): string {
|
||||
if (!raw) return "—"
|
||||
const normalized = raw.replace(" +0000 UTC", "Z").replace(/^(\d{4}-\d{2}-\d{2}) /, "$1T")
|
||||
const d = new Date(normalized)
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
const datePart = raw.split(" ")[0]
|
||||
return datePart || raw
|
||||
}
|
||||
return d.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
export function versionLabel(version: Pick<AppVersion, "version_name" | "version_code">): string {
|
||||
return `v${version.version_name} (${version.version_code})`
|
||||
}
|
||||
9
src/lib/auth.ts
Normal file
9
src/lib/auth.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/** Clear session tokens and redirect to login (full page navigation). */
|
||||
export function logoutToLogin(options?: { passwordChanged?: boolean }) {
|
||||
localStorage.removeItem("access_token")
|
||||
localStorage.removeItem("refresh_token")
|
||||
localStorage.removeItem("member_id")
|
||||
localStorage.removeItem("role")
|
||||
const search = options?.passwordChanged ? "?password_changed=1" : ""
|
||||
window.location.href = `/login${search}`
|
||||
}
|
||||
51
src/lib/payments.ts
Normal file
51
src/lib/payments.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { formatPlanCategory } from "./subscriptionPlans"
|
||||
import type { Payment } from "../types/payment.types"
|
||||
|
||||
export function formatPaymentAmount(payment: Pick<Payment, "amount" | "currency">): string {
|
||||
const amount = Number(payment.amount)
|
||||
const formatted = Number.isFinite(amount)
|
||||
? amount.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 })
|
||||
: String(payment.amount)
|
||||
return `${formatted} ${payment.currency || "ETB"}`
|
||||
}
|
||||
|
||||
export function formatPaymentDate(iso: string | null | undefined): string {
|
||||
if (!iso) return "—"
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return d.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
export function formatPaymentStatus(status: string): string {
|
||||
return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
export function formatPaymentMethod(method: string): string {
|
||||
if (!method) return "—"
|
||||
return method.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
export function paymentCustomerName(payment: Payment): string {
|
||||
const name = [payment.user_first_name, payment.user_last_name].filter(Boolean).join(" ")
|
||||
return name || payment.user_email || `User #${payment.user_id}`
|
||||
}
|
||||
|
||||
export function formatPaymentPlanCategory(category: string): string {
|
||||
return formatPlanCategory(category)
|
||||
}
|
||||
|
||||
export function paymentStatusBadgeVariant(
|
||||
status: string,
|
||||
): "success" | "warning" | "destructive" | "secondary" | "info" {
|
||||
const s = status.toUpperCase()
|
||||
if (s === "SUCCESS" || s === "COMPLETED" || s === "PAID") return "success"
|
||||
if (s === "PENDING" || s === "PROCESSING") return "warning"
|
||||
if (s === "FAILED" || s === "CANCELLED" || s === "EXPIRED") return "destructive"
|
||||
return "secondary"
|
||||
}
|
||||
61
src/lib/subscriptionPlans.ts
Normal file
61
src/lib/subscriptionPlans.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import type {
|
||||
SubscriptionPlan,
|
||||
SubscriptionPlanCategory,
|
||||
SubscriptionPlanDurationUnit,
|
||||
} from "../types/subscription.types"
|
||||
|
||||
export const SUBSCRIPTION_PLAN_CATEGORIES: {
|
||||
value: SubscriptionPlanCategory
|
||||
label: string
|
||||
}[] = [
|
||||
{ value: "LEARN_ENGLISH", label: "Learn English" },
|
||||
{ value: "EXAM_PREP", label: "Exam prep" },
|
||||
{ value: "SKILLS", label: "Skills" },
|
||||
]
|
||||
|
||||
export const SUBSCRIPTION_DURATION_UNITS: {
|
||||
value: SubscriptionPlanDurationUnit
|
||||
label: string
|
||||
}[] = [
|
||||
{ value: "DAY", label: "Day(s)" },
|
||||
{ value: "WEEK", label: "Week(s)" },
|
||||
{ value: "MONTH", label: "Month(s)" },
|
||||
{ value: "YEAR", label: "Year(s)" },
|
||||
]
|
||||
|
||||
export const SUBSCRIPTION_CURRENCIES = ["ETB", "USD"] as const
|
||||
|
||||
export function formatPlanDuration(plan: Pick<SubscriptionPlan, "duration_value" | "duration_unit">): string {
|
||||
const v = plan.duration_value
|
||||
const u = String(plan.duration_unit).toUpperCase()
|
||||
const word =
|
||||
u === "MONTH" ? "month" : u === "YEAR" ? "year" : u === "WEEK" ? "week" : u === "DAY" ? "day" : plan.duration_unit
|
||||
if (u === "MONTH" || u === "YEAR" || u === "WEEK" || u === "DAY") {
|
||||
return `${v} ${v === 1 ? word : `${word}s`}`
|
||||
}
|
||||
return `${v} ${word}`
|
||||
}
|
||||
|
||||
export function formatPlanPrice(plan: Pick<SubscriptionPlan, "price" | "currency">): string {
|
||||
const amount = Number(plan.price)
|
||||
const formatted = Number.isFinite(amount)
|
||||
? amount.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 })
|
||||
: String(plan.price)
|
||||
return `${formatted} ${plan.currency}`
|
||||
}
|
||||
|
||||
export function formatPlanCategory(category: string): string {
|
||||
const match = SUBSCRIPTION_PLAN_CATEGORIES.find((c) => c.value === category)
|
||||
if (match) return match.label
|
||||
return category.replace(/_/g, " ").toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
export function formatPlanCreatedAt(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
64
src/lib/theme.ts
Normal file
64
src/lib/theme.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
export type ThemeMode = "light" | "dark" | "system"
|
||||
export type ResolvedTheme = "light" | "dark"
|
||||
|
||||
export const THEME_STORAGE_KEY = "yimaru-admin-theme"
|
||||
|
||||
const MEDIA_QUERY = "(prefers-color-scheme: dark)"
|
||||
|
||||
export function getSystemTheme(): ResolvedTheme {
|
||||
if (typeof window === "undefined") return "light"
|
||||
return window.matchMedia(MEDIA_QUERY).matches ? "dark" : "light"
|
||||
}
|
||||
|
||||
/** Resolved appearance: light mode always forces light; dark forces dark; system follows OS. */
|
||||
export function resolveTheme(mode: ThemeMode): ResolvedTheme {
|
||||
if (mode === "light") return "light"
|
||||
if (mode === "dark") return "dark"
|
||||
return getSystemTheme()
|
||||
}
|
||||
|
||||
export function getStoredTheme(): ThemeMode {
|
||||
if (typeof window === "undefined") return "light"
|
||||
const value = localStorage.getItem(THEME_STORAGE_KEY)
|
||||
if (value === "light" || value === "dark" || value === "system") return value
|
||||
return "light"
|
||||
}
|
||||
|
||||
function syncMetaThemeColor(resolved: ResolvedTheme) {
|
||||
if (typeof document === "undefined") return
|
||||
let meta = document.querySelector('meta[name="theme-color"]') as HTMLMetaElement | null
|
||||
if (!meta) {
|
||||
meta = document.createElement("meta")
|
||||
meta.name = "theme-color"
|
||||
document.head.appendChild(meta)
|
||||
}
|
||||
meta.content = resolved === "dark" ? "#12121a" : "#f5f5f5"
|
||||
}
|
||||
|
||||
export function applyTheme(mode: ThemeMode): ResolvedTheme {
|
||||
const resolved = resolveTheme(mode)
|
||||
const root = document.documentElement
|
||||
|
||||
root.classList.remove("dark")
|
||||
if (resolved === "dark") {
|
||||
root.classList.add("dark")
|
||||
}
|
||||
|
||||
root.dataset.theme = resolved
|
||||
root.dataset.themePreference = mode
|
||||
root.style.colorScheme = resolved
|
||||
|
||||
syncMetaThemeColor(resolved)
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
export function watchSystemTheme(onChange: (resolved: ResolvedTheme) => void): () => void {
|
||||
if (typeof window === "undefined") return () => undefined
|
||||
|
||||
const media = window.matchMedia(MEDIA_QUERY)
|
||||
const handler = () => onChange(getSystemTheme())
|
||||
|
||||
media.addEventListener("change", handler)
|
||||
return () => media.removeEventListener("change", handler)
|
||||
}
|
||||
|
|
@ -3,11 +3,14 @@ import { createRoot } from 'react-dom/client'
|
|||
import { BrowserRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { ThemeProvider } from './contexts/ThemeContext.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import {
|
|||
getVideoLessonsSummary,
|
||||
} from "../lib/analytics"
|
||||
import type { DashboardData, DashboardFilters } from "../types/analytics.types"
|
||||
import { formatPlanDuration } from "../lib/subscriptionPlans"
|
||||
import type { SubscriptionPlan } from "../types/subscription.types"
|
||||
import type { Rating } from "../types/course.types"
|
||||
|
||||
|
|
@ -59,17 +60,6 @@ function formatDate(dateStr: string) {
|
|||
|
||||
const DEFAULT_FILTERS: DashboardFilters = { mode: "all_time" }
|
||||
|
||||
function formatPlanDuration(plan: SubscriptionPlan): string {
|
||||
const v = plan.duration_value
|
||||
const u = plan.duration_unit.toUpperCase()
|
||||
const word =
|
||||
u === "MONTH" ? "month" : u === "YEAR" ? "year" : u === "WEEK" ? "week" : u === "DAY" ? "day" : plan.duration_unit
|
||||
if (u === "MONTH" || u === "YEAR" || u === "WEEK" || u === "DAY") {
|
||||
return `${v} ${v === 1 ? word : `${word}s`}`
|
||||
}
|
||||
return `${v} ${word}`
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const [userFirstName, setUserFirstName] = useState<string>("")
|
||||
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
||||
|
|
@ -120,7 +110,7 @@ export function DashboardPage() {
|
|||
setSubscriptionPlansLoading(true)
|
||||
try {
|
||||
const res = await getSubscriptionPlans()
|
||||
setSubscriptionPlans(res.data.data)
|
||||
setSubscriptionPlans(res.data)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setSubscriptionPlans([])
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import {
|
|||
Eye,
|
||||
EyeOff,
|
||||
Globe,
|
||||
KeyRound,
|
||||
Languages,
|
||||
Lock,
|
||||
Moon,
|
||||
Palette,
|
||||
|
|
@ -14,8 +12,7 @@ import {
|
|||
Sun,
|
||||
User,
|
||||
CreditCard,
|
||||
AlertTriangle,
|
||||
X,
|
||||
Smartphone,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -27,29 +24,29 @@ import { Input } from "../components/ui/input";
|
|||
import { Button } from "../components/ui/button";
|
||||
import { Select } from "../components/ui/select";
|
||||
import { Separator } from "../components/ui/separator";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../components/ui/dialog";
|
||||
import { cn } from "../lib/utils";
|
||||
import { SpinnerIcon } from "../components/ui/spinner-icon";
|
||||
import { changeTeamMemberPassword } from "../api/team.api";
|
||||
import { logoutToLogin } from "../lib/auth";
|
||||
import { getMyProfile, updateProfile } from "../api/users.api";
|
||||
import type { UserProfileData } from "../types/user.types";
|
||||
import { toast } from "sonner";
|
||||
import { AppVersionsTab } from "./settings/AppVersionsTab";
|
||||
import { SubscriptionPlansTab } from "./settings/SubscriptionPlansTab";
|
||||
import { ThemeModePreview } from "./settings/components/ThemeModePreview";
|
||||
import { useTheme } from "../contexts/ThemeContext";
|
||||
|
||||
type SettingsTab =
|
||||
| "subscription"
|
||||
| "app-versions"
|
||||
| "profile"
|
||||
| "security"
|
||||
| "notifications"
|
||||
| "appearance";
|
||||
|
||||
const tabs: { id: SettingsTab; label: string; icon: typeof User }[] = [
|
||||
{ id: "subscription", label: "Subscription", icon: CreditCard },
|
||||
{ id: "subscription", label: "Subscription packages", icon: CreditCard },
|
||||
{ id: "app-versions", label: "App versions", icon: Smartphone },
|
||||
{ id: "profile", label: "Profile", icon: User },
|
||||
{ id: "security", label: "Security", icon: Shield },
|
||||
{ id: "notifications", label: "Notifications", icon: Bell },
|
||||
|
|
@ -111,143 +108,6 @@ function SettingRow({
|
|||
);
|
||||
}
|
||||
|
||||
// --- Subscription Tab ---
|
||||
|
||||
function SubscriptionTab() {
|
||||
const [subs, setSubs] = useState([
|
||||
{
|
||||
id: "auto_renew",
|
||||
name: "Auto-renewal",
|
||||
desc: "Automatically renew your subscription when it expires",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "marketing_emails",
|
||||
name: "Marketing Emails",
|
||||
desc: "Receive updates about new features and promotions",
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "priority_support",
|
||||
name: "Priority Support",
|
||||
desc: "Access 24/7 priority customer support",
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const [pendingToggle, setPendingToggle] = useState<string | null>(null);
|
||||
const [showWarning, setShowWarning] = useState(false);
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
const item = subs.find((s) => s.id === id);
|
||||
if (item?.enabled) {
|
||||
setPendingToggle(id);
|
||||
setShowWarning(true);
|
||||
} else {
|
||||
setSubs((prev) =>
|
||||
prev.map((s) => (s.id === id ? { ...s, enabled: true } : s)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmToggleOff = () => {
|
||||
if (pendingToggle) {
|
||||
setSubs((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === pendingToggle ? { ...s, enabled: false } : s,
|
||||
),
|
||||
);
|
||||
setShowWarning(false);
|
||||
setPendingToggle(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden">
|
||||
<CardHeader className="pb-3 border-b border-grayScale-50">
|
||||
<CardTitle className="text-sm font-bold text-grayScale-900">
|
||||
Subscription Features
|
||||
</CardTitle>
|
||||
<p className="text-[11px] text-grayScale-500">
|
||||
Customize your subscription experience and management preferences
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-0 p-0">
|
||||
{subs.map((sub, idx) => (
|
||||
<React.Fragment key={sub.id}>
|
||||
<div
|
||||
className={cn(
|
||||
"px-2",
|
||||
idx < subs.length - 1 && "border-b border-grayScale-50",
|
||||
)}
|
||||
>
|
||||
<SettingRow
|
||||
icon={CreditCard}
|
||||
title={sub.name}
|
||||
description={sub.desc}
|
||||
>
|
||||
<Toggle
|
||||
enabled={sub.enabled}
|
||||
onToggle={() => handleToggle(sub.id)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={showWarning} onOpenChange={setShowWarning}>
|
||||
<DialogContent className="max-w-md p-0 overflow-hidden border border-grayScale-100 rounded-[12px] shadow-2xl">
|
||||
<div className="relative p-8">
|
||||
<div className="flex items-start gap-5 mb-6">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-500 border border-red-100">
|
||||
<AlertTriangle className="h-7 w-7" />
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<h3 className="text-xl font-bold text-grayScale-900 tracking-tight">
|
||||
Are you absolutely sure?
|
||||
</h3>
|
||||
<p className="text-sm text-grayScale-500 mt-1">
|
||||
Disabling this feature might limit your experience.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-grayScale-50/80 border border-grayScale-100 p-5 rounded-[8px] mb-8">
|
||||
<p className="text-sm text-grayScale-600 leading-relaxed font-medium">
|
||||
By turning this off, you will no longer receive the benefits
|
||||
associated with this feature. Some changes might take up to 24
|
||||
hours to reflect.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmToggleOff}
|
||||
className="w-full rounded-[8px] py-6 text-sm font-bold bg-red-500 hover:bg-red-600 text-white border-none shadow-sm transition-all active:scale-[0.98]"
|
||||
>
|
||||
Yes, Disable Feature
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowWarning(false)}
|
||||
className="w-full rounded-[8px] py-6 text-sm font-bold border-grayScale-200 text-grayScale-600 hover:bg-grayScale-50 transition-all active:scale-[0.98]"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Other Tabs (Existing, but with sidebar layout updates) ---
|
||||
|
||||
function ProfileTab({ profile }: { profile: UserProfileData }) {
|
||||
const [firstName, setFirstName] = useState(profile.first_name);
|
||||
const [lastName, setLastName] = useState(profile.last_name);
|
||||
|
|
@ -363,17 +223,46 @@ function ProfileTab({ profile }: { profile: UserProfileData }) {
|
|||
);
|
||||
}
|
||||
|
||||
function SecurityTab() {
|
||||
function SecurityTab({ memberId }: { memberId: number }) {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showCurrent, setShowCurrent] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (!currentPassword.trim()) {
|
||||
toast.error("Enter your current password.");
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
toast.error("New password must be at least 8 characters.");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error("New password and confirmation do not match.");
|
||||
return;
|
||||
}
|
||||
if (currentPassword === newPassword) {
|
||||
toast.error("New password must be different from your current password.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
toast.success("Password updated successfully");
|
||||
await changeTeamMemberPassword(memberId, {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
logoutToLogin({ passwordChanged: true });
|
||||
return;
|
||||
} catch (e: unknown) {
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed to update password.";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -397,7 +286,11 @@ function SecurityTab() {
|
|||
<Input
|
||||
type={showCurrent ? "text" : "password"}
|
||||
placeholder="Enter current password"
|
||||
className="rounded-[6px]"
|
||||
className="rounded-[6px] pr-10"
|
||||
autoComplete="current-password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -421,7 +314,11 @@ function SecurityTab() {
|
|||
<Input
|
||||
type={showNew ? "text" : "password"}
|
||||
placeholder="Enter new password"
|
||||
className="rounded-[6px]"
|
||||
className="rounded-[6px] pr-10"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -444,7 +341,11 @@ function SecurityTab() {
|
|||
<Input
|
||||
type={showConfirm ? "text" : "password"}
|
||||
placeholder="Confirm new password"
|
||||
className="rounded-[6px]"
|
||||
className="rounded-[6px] pr-10"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -532,52 +433,101 @@ function NotificationsTab() {
|
|||
}
|
||||
|
||||
function AppearanceTab() {
|
||||
const [theme, setTheme] = useState<"light" | "dark" | "system">("light");
|
||||
const { theme, setTheme, resolvedTheme, systemTheme } = useTheme();
|
||||
|
||||
const options = [
|
||||
{
|
||||
id: "light" as const,
|
||||
label: "Light",
|
||||
description: "Always bright UI",
|
||||
icon: Sun,
|
||||
preview: "light" as const,
|
||||
},
|
||||
{
|
||||
id: "dark" as const,
|
||||
label: "Dark",
|
||||
description: "Always dark UI",
|
||||
icon: Moon,
|
||||
preview: "dark" as const,
|
||||
},
|
||||
{
|
||||
id: "system" as const,
|
||||
label: "System",
|
||||
description: `Follows device (${systemTheme})`,
|
||||
icon: Globe,
|
||||
preview: "system" as const,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="border border-grayScale-100 rounded-[6px] overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<Card className="overflow-hidden rounded-[6px] border border-grayScale-200">
|
||||
<div className="h-1 w-full bg-brand-400" />
|
||||
<CardHeader className="pb-3 border-b border-grayScale-50">
|
||||
<CardTitle className="text-sm font-bold text-grayScale-900">
|
||||
Theme
|
||||
</CardTitle>
|
||||
<CardHeader className="border-b border-grayScale-200 pb-3">
|
||||
<CardTitle className="text-sm font-bold text-grayScale-600">Theme</CardTitle>
|
||||
<p className="text-xs text-grayScale-400">
|
||||
Active appearance:{" "}
|
||||
<span className="font-semibold capitalize text-grayScale-600">{resolvedTheme}</span>
|
||||
{theme === "system" ? " (from your device setting)" : null}
|
||||
{theme === "light" ? " (fixed — not tied to device)" : null}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-6">
|
||||
<CardContent className="pb-6 pt-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{(
|
||||
[
|
||||
{ id: "light", label: "Light", icon: Sun },
|
||||
{ id: "dark", label: "Dark", icon: Moon },
|
||||
{ id: "system", label: "System", icon: Globe },
|
||||
] as const
|
||||
).map(({ id, label, icon: Icon }) => (
|
||||
{options.map(({ id, label, description, icon: Icon, preview }) => {
|
||||
const selected = theme === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setTheme(id)}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-2.5 rounded-[6px] border-2 px-4 py-5 transition-all",
|
||||
theme === id
|
||||
? "border-brand-500 bg-brand-50 text-brand-600 shadow-sm"
|
||||
: "border-grayScale-100 bg-white text-grayScale-400 hover:border-grayScale-200 hover:bg-grayScale-50",
|
||||
"flex flex-col items-stretch gap-3 rounded-[8px] border-2 p-3 text-left transition-all",
|
||||
selected
|
||||
? "border-brand-500 bg-brand-500/10 shadow-sm ring-1 ring-brand-500/30"
|
||||
: "border-grayScale-200 bg-grayScale-50 hover:border-grayScale-300 hover:bg-grayScale-100",
|
||||
)}
|
||||
>
|
||||
<ThemeModePreview
|
||||
variant={preview}
|
||||
systemResolved={systemTheme}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-[6px]",
|
||||
theme === id
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-[6px]",
|
||||
selected
|
||||
? "bg-brand-500 text-white"
|
||||
: "bg-grayScale-100 text-grayScale-400",
|
||||
: "bg-grayScale-100 text-grayScale-500",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-semibold",
|
||||
selected ? "text-grayScale-600" : "text-grayScale-500",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-[11px] text-grayScale-400">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-4 rounded-[6px] border border-dashed border-grayScale-200 bg-grayScale-100 px-3 py-2 text-[11px] leading-relaxed text-grayScale-500">
|
||||
<strong className="font-semibold text-grayScale-600">Light vs System:</strong> Light
|
||||
always stays bright. System copies your Windows/macOS theme — if your device is in
|
||||
light mode, System will match Light; switch your device to dark to see System use the
|
||||
dark admin theme.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -644,10 +594,37 @@ export function SettingsPage() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Content Area */}
|
||||
<main className="min-h-[400px]">
|
||||
{activeTab === "subscription" && <SubscriptionTab />}
|
||||
<div className="flex min-w-0 flex-col gap-8 lg:flex-row lg:items-start">
|
||||
<nav className="flex shrink-0 flex-row gap-1 overflow-x-auto rounded-[8px] border border-grayScale-100 bg-white p-1 lg:w-56 lg:flex-col">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const active = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 whitespace-nowrap rounded-[6px] px-3 py-2.5 text-left text-sm font-medium transition-colors",
|
||||
active
|
||||
? "bg-brand-50 text-brand-600"
|
||||
: "text-grayScale-600 hover:bg-grayScale-50",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<main className="min-h-[400px] min-w-0 w-full flex-1">
|
||||
{activeTab === "subscription" && <SubscriptionPlansTab />}
|
||||
{activeTab === "app-versions" && <AppVersionsTab />}
|
||||
{activeTab === "profile" && <ProfileTab profile={profile} />}
|
||||
{activeTab === "security" && <SecurityTab memberId={profile.id} />}
|
||||
{activeTab === "notifications" && <NotificationsTab />}
|
||||
{activeTab === "appearance" && <AppearanceTab />}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -665,6 +665,11 @@ export function AnalyticsPage() {
|
|||
data={users.by_age_group ?? []}
|
||||
total={users.total_users}
|
||||
/>
|
||||
<BreakdownList
|
||||
title="Country"
|
||||
data={users.by_country ?? []}
|
||||
total={users.total_users}
|
||||
/>
|
||||
<BreakdownList
|
||||
title="Region"
|
||||
data={users.by_region ?? []}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Link, Navigate, useNavigate } from "react-router-dom";
|
||||
import { Link, Navigate, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
import { BrandLogo } from "../../components/brand/BrandLogo";
|
||||
|
|
@ -65,9 +65,18 @@ function GoogleIcon({ className }: { className?: string }) {
|
|||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const token = localStorage.getItem("access_token");
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("password_changed") !== "1") return;
|
||||
toast.success("Password updated", {
|
||||
description: "Sign in with your new password.",
|
||||
});
|
||||
setSearchParams({}, { replace: true });
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { Outlet } from "react-router-dom";
|
||||
import { ContentHierarchyList } from "./components/ContentHierarchyList";
|
||||
|
||||
export function ContentManagementLayout() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<div className="h-9 w-1 rounded-full bg-gradient-to-b from-brand-500 to-brand-600" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900 sm:text-[1.65rem]">
|
||||
|
|
@ -16,8 +15,6 @@ export function ContentManagementLayout() {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContentHierarchyList />
|
||||
</div>
|
||||
|
||||
<Outlet />
|
||||
|
|
|
|||
589
src/pages/payments/PaymentsPage.tsx
Normal file
589
src/pages/payments/PaymentsPage.tsx
Normal file
|
|
@ -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<Payment[]>([])
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [statusFilter, setStatusFilter] = useState<PaymentStatus | "">("")
|
||||
const [providerFilter, setProviderFilter] = useState<PaymentProvider | "">("")
|
||||
const [planCategoryFilter, setPlanCategoryFilter] = useState<PaymentPlanCategory | "">("")
|
||||
const [selected, setSelected] = useState<Payment | null>(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 (
|
||||
<div className="mx-auto min-w-0 w-full max-w-7xl space-y-6 px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-bold uppercase tracking-wider text-brand-500">
|
||||
Billing
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-grayScale-900">Payments</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-grayScale-500">
|
||||
Browse checkout transactions from{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-xs">GET /admin/payments</code>.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="shrink-0 rounded-[6px]"
|
||||
disabled={loading}
|
||||
onClick={() => void fetchPayments(offset, pageSize, listFilters)}
|
||||
>
|
||||
<RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card className="overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
|
||||
<div className="h-1 bg-brand-500" />
|
||||
<CardContent className="flex items-center gap-4 p-5">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-brand-50 text-brand-600">
|
||||
<CreditCard className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-grayScale-500">Total transactions</p>
|
||||
<p className="text-2xl font-bold text-grayScale-900">{totalCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
|
||||
<div className="h-1 bg-mint-500" />
|
||||
<CardContent className="flex items-center gap-4 p-5">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-mint-50 text-mint-600">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-grayScale-500">Successful (this page)</p>
|
||||
<p className="text-2xl font-bold text-grayScale-900">{successfulOnPage.length}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
|
||||
<div className="h-1 bg-amber-500" />
|
||||
<CardContent className="flex items-center gap-4 p-5">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-amber-50 text-amber-700">
|
||||
<Wallet className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-grayScale-500">Revenue (this page)</p>
|
||||
<p className="text-2xl font-bold text-grayScale-900">
|
||||
{pageRevenue.toLocaleString()} ETB
|
||||
</p>
|
||||
<p className="text-[11px] text-grayScale-400">{pendingOnPage} pending on page</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
|
||||
<CardHeader className="border-b border-grayScale-50 pb-4">
|
||||
<CardTitle className="text-sm font-bold text-grayScale-900">Transaction history</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="min-w-0 space-y-4 p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-3 rounded-[8px] border border-grayScale-100 bg-grayScale-50/40 p-3 sm:p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="mr-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Status
|
||||
</span>
|
||||
{STATUS_FILTERS.map(({ value, label }) => (
|
||||
<FilterChip
|
||||
key={value}
|
||||
label={label}
|
||||
active={statusFilter === value}
|
||||
disabled={loading}
|
||||
onClick={() => toggleStatus(value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="mr-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Provider
|
||||
</span>
|
||||
{PROVIDER_FILTERS.map(({ value, label }) => (
|
||||
<FilterChip
|
||||
key={value}
|
||||
label={label}
|
||||
active={providerFilter === value}
|
||||
disabled={loading}
|
||||
onClick={() => toggleProvider(value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="mr-1 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Plan
|
||||
</span>
|
||||
{PLAN_CATEGORY_FILTERS.map(({ value, label }) => (
|
||||
<FilterChip
|
||||
key={value}
|
||||
label={label}
|
||||
active={planCategoryFilter === value}
|
||||
disabled={loading}
|
||||
onClick={() => togglePlanCategory(value)}
|
||||
/>
|
||||
))}
|
||||
{hasActiveFilters ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-7 rounded-[6px] px-2 text-xs text-grayScale-500"
|
||||
disabled={loading}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
||||
<SpinnerIcon className="h-8 w-8 text-brand-500" />
|
||||
<p className="text-sm text-grayScale-500">Loading payments…</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-[8px] border border-dashed border-grayScale-200 py-16">
|
||||
<p className="text-sm font-medium text-grayScale-700">Could not load payments</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-[6px]"
|
||||
onClick={() => void fetchPayments(offset, pageSize, listFilters)}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : payments.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-[8px] border border-dashed border-grayScale-200 py-16 text-center">
|
||||
<CreditCard className="h-10 w-10 text-grayScale-300" />
|
||||
<p className="text-sm font-medium text-grayScale-700">
|
||||
{hasActiveFilters ? "No payments match these filters" : "No payments yet"}
|
||||
</p>
|
||||
<p className="max-w-sm text-xs text-grayScale-500">
|
||||
{hasActiveFilters
|
||||
? "Try different filters or clear them to see more results."
|
||||
: "Transactions will appear here once customers complete checkout."}
|
||||
</p>
|
||||
{hasActiveFilters ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-[6px]"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-0 w-full max-w-full overflow-x-auto rounded-[8px] border border-grayScale-100">
|
||||
<table className="w-full min-w-[900px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-grayScale-100 bg-grayScale-50/80 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Transaction</th>
|
||||
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Customer</th>
|
||||
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Plan</th>
|
||||
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Amount</th>
|
||||
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Method</th>
|
||||
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Status</th>
|
||||
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Paid</th>
|
||||
<th className="sticky right-0 z-10 whitespace-nowrap bg-grayScale-50/95 px-3 py-3 text-right sm:px-4">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-grayScale-50">
|
||||
{payments.map((payment) => (
|
||||
<tr key={payment.id} className="group transition-colors hover:bg-grayScale-50/60">
|
||||
<td className="whitespace-nowrap px-3 py-3 sm:px-4 sm:py-4">
|
||||
<p className="font-semibold text-grayScale-900">#{payment.id}</p>
|
||||
<p className="mt-0.5 max-w-[160px] truncate font-mono text-[11px] text-grayScale-500">
|
||||
{payment.transaction_id || payment.session_id || "—"}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-3 py-3 sm:px-4 sm:py-4">
|
||||
<p className="font-medium text-grayScale-900">
|
||||
{paymentCustomerName(payment)}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-xs text-grayScale-500">
|
||||
{payment.user_email || `User #${payment.user_id}`}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-3 py-3 sm:px-4 sm:py-4">
|
||||
<p className="max-w-[180px] truncate font-medium text-grayScale-800">
|
||||
{payment.plan_name || `Plan #${payment.plan_id}`}
|
||||
</p>
|
||||
{payment.plan_category ? (
|
||||
<Badge variant="secondary" className="mt-1 text-[10px]">
|
||||
{formatPaymentPlanCategory(payment.plan_category)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3 font-semibold text-grayScale-900 sm:px-4 sm:py-4">
|
||||
{formatPaymentAmount(payment)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3 sm:px-4 sm:py-4">
|
||||
<Badge variant="info">{formatPaymentMethod(payment.payment_method)}</Badge>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3 sm:px-4 sm:py-4">
|
||||
<Badge variant={paymentStatusBadgeVariant(payment.status)}>
|
||||
{formatPaymentStatus(payment.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3 text-xs text-grayScale-600 sm:px-4 sm:py-4">
|
||||
{formatPaymentDate(payment.paid_at ?? payment.created_at)}
|
||||
</td>
|
||||
<td className="sticky right-0 z-10 whitespace-nowrap bg-white px-3 py-3 shadow-[-8px_0_12px_-8px_rgba(0,0,0,0.08)] group-hover:bg-grayScale-50/60 sm:px-4 sm:py-4">
|
||||
<div className="flex justify-end gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 rounded-[6px] p-0 text-grayScale-500 hover:text-brand-600"
|
||||
aria-label="View payment details"
|
||||
onClick={() => setSelected(payment)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 rounded-[6px] p-0 text-grayScale-500 hover:text-brand-600"
|
||||
aria-label="Copy transaction ID"
|
||||
onClick={() =>
|
||||
copyText(
|
||||
payment.transaction_id || payment.session_id,
|
||||
"Transaction ID",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && totalCount > 0 ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-grayScale-100 pt-4">
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-grayScale-500">
|
||||
<span>
|
||||
Showing {pageStart}–{pageEnd} of {totalCount}
|
||||
</span>
|
||||
<span className="hidden h-4 w-px bg-grayScale-200 sm:inline" />
|
||||
<span className="flex items-center gap-2">
|
||||
Rows per page
|
||||
<div className="relative">
|
||||
<select
|
||||
value={pageSize}
|
||||
disabled={loading}
|
||||
onChange={(e) => {
|
||||
setPageSize(Number(e.target.value))
|
||||
setOffset(0)
|
||||
}}
|
||||
className="h-8 appearance-none rounded-md border bg-white pl-2 pr-7 text-sm font-medium text-grayScale-600 focus:outline-none"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-grayScale-400" />
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-[6px]"
|
||||
disabled={!canPrev || loading}
|
||||
onClick={() => setOffset((o) => Math.max(0, o - pageSize))}
|
||||
>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-[6px]"
|
||||
disabled={!canNext || loading}
|
||||
onClick={() => setOffset((o) => o + pageSize)}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={selected != null} onOpenChange={(open) => !open && setSelected(null)}>
|
||||
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto rounded-[12px]">
|
||||
{selected ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Payment #{selected.id}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatPaymentStatus(selected.status)} · {formatPaymentAmount(selected)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<Detail label="Customer" value={paymentCustomerName(selected)} />
|
||||
<Detail label="Email" value={selected.user_email || "—"} />
|
||||
<Detail
|
||||
label="User"
|
||||
value={
|
||||
<Link
|
||||
to={`/users/${selected.user_id}`}
|
||||
className="font-medium text-brand-500 hover:text-brand-600"
|
||||
>
|
||||
View user #{selected.user_id}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Detail label="Plan" value={selected.plan_name || `Plan #${selected.plan_id}`} />
|
||||
<Detail
|
||||
label="Category"
|
||||
value={formatPaymentPlanCategory(selected.plan_category)}
|
||||
/>
|
||||
<Detail label="Method" value={formatPaymentMethod(selected.payment_method)} />
|
||||
<Detail label="Status" value={formatPaymentStatus(selected.status)} />
|
||||
<Detail label="Transaction ID" value={selected.transaction_id || "—"} mono />
|
||||
<Detail label="Session ID" value={selected.session_id || "—"} mono />
|
||||
<Detail label="Subscription" value={`#${selected.subscription_id}`} />
|
||||
<Detail label="Paid at" value={formatPaymentDate(selected.paid_at)} />
|
||||
<Detail label="Expires at" value={formatPaymentDate(selected.expires_at)} />
|
||||
<Detail label="Created" value={formatPaymentDate(selected.created_at)} />
|
||||
<Detail label="Updated" value={formatPaymentDate(selected.updated_at)} />
|
||||
</dl>
|
||||
{selected.payment_url ? (
|
||||
<a
|
||||
href={selected.payment_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex text-sm font-semibold text-brand-500 hover:text-brand-600"
|
||||
>
|
||||
Open checkout URL
|
||||
</a>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterChip({
|
||||
label,
|
||||
active,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
active: boolean
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-[6px] border px-2.5 py-1 text-xs font-semibold transition-colors",
|
||||
active
|
||||
? "border-brand-500 bg-brand-500 text-white"
|
||||
: "border-grayScale-200 bg-white text-grayScale-600 hover:border-brand-200 hover:text-brand-600",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Detail({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
}: {
|
||||
label: string
|
||||
value: ReactNode
|
||||
mono?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[8px] border border-grayScale-100 bg-grayScale-50/50 px-3 py-2">
|
||||
<dt className="text-[10px] font-bold uppercase tracking-wider text-grayScale-400">{label}</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
"mt-0.5 font-medium text-grayScale-800 break-all",
|
||||
mono && "font-mono text-xs",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
501
src/pages/settings/AppVersionsTab.tsx
Normal file
501
src/pages/settings/AppVersionsTab.tsx
Normal file
|
|
@ -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 <Apple className="h-4 w-4" />
|
||||
}
|
||||
return <Smartphone className="h-4 w-4" />
|
||||
}
|
||||
|
||||
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<AppVersion[]>([])
|
||||
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<AppVersion | null>(null)
|
||||
const [versionToDelete, setVersionToDelete] = useState<AppVersion | null>(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 (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2 min-w-0 w-full max-w-full space-y-6 duration-300">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-bold uppercase tracking-wider text-brand-500">
|
||||
Mobile releases
|
||||
</p>
|
||||
<h2 className="text-lg font-bold text-grayScale-900">App version control</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm text-grayScale-500">
|
||||
Manage Android and iOS release metadata for in-app update prompts. Versions are loaded
|
||||
from{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-xs">
|
||||
GET /admin/app-versions
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
className="shrink-0 rounded-[6px] bg-brand-500 font-semibold text-white hover:bg-brand-600"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New version
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="shrink-0 rounded-[6px]"
|
||||
disabled={loading}
|
||||
onClick={() => void load()}
|
||||
>
|
||||
<RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
|
||||
<div className="h-1 bg-brand-500" />
|
||||
<CardContent className="flex items-center gap-4 p-5">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-brand-50 text-brand-600">
|
||||
<TabletSmartphone className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-grayScale-500">Total (this page)</p>
|
||||
<p className="text-2xl font-bold text-grayScale-900">{totalCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
|
||||
<div className="h-1 bg-mint-500" />
|
||||
<CardContent className="flex items-center gap-4 p-5">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-mint-50 text-mint-600">
|
||||
<Smartphone className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-grayScale-500">Android · iOS</p>
|
||||
<p className="text-2xl font-bold text-grayScale-900">
|
||||
{androidCount} · {iosCount}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
|
||||
<div className="h-1 bg-sky-500" />
|
||||
<CardContent className="flex items-center gap-4 p-5">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-sky-50 text-sky-600">
|
||||
<Calendar className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-grayScale-500">Active on page</p>
|
||||
<p className="text-2xl font-bold text-grayScale-900">{activeCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
|
||||
<div className="h-1 bg-destructive" />
|
||||
<CardContent className="flex items-center gap-4 p-5">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-red-50 text-destructive">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-grayScale-500">Force updates</p>
|
||||
<p className="text-2xl font-bold text-grayScale-900">{forceCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="min-w-0 rounded-[8px] border border-grayScale-100 shadow-none">
|
||||
<CardHeader className="border-b border-grayScale-50 pb-4">
|
||||
<CardTitle className="text-sm font-bold text-grayScale-900">Release history</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="min-w-0 space-y-4 p-4 sm:p-6">
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<div className="relative w-full min-w-0">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
||||
<Input
|
||||
className="w-full rounded-[6px] pl-9"
|
||||
placeholder="Search version, notes, platform…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap gap-2">
|
||||
{(
|
||||
[
|
||||
{ id: "all", label: "All platforms" },
|
||||
{ id: "ANDROID", label: "Android" },
|
||||
{ id: "IOS", label: "iOS" },
|
||||
] as const
|
||||
).map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setPlatformFilter(tab.id)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1.5 text-xs font-semibold transition-colors",
|
||||
platformFilter === tab.id
|
||||
? "bg-brand-500 text-white"
|
||||
: "bg-grayScale-100 text-grayScale-600 hover:bg-grayScale-200",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap gap-2">
|
||||
{(
|
||||
[
|
||||
{ id: "all", label: "All statuses" },
|
||||
{ id: "ACTIVE", label: "Active" },
|
||||
{ id: "INACTIVE", label: "Inactive" },
|
||||
{ id: "DRAFT", label: "Draft" },
|
||||
] as const
|
||||
).map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(tab.id)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1.5 text-xs font-semibold transition-colors",
|
||||
statusFilter === tab.id
|
||||
? "bg-grayScale-800 text-white"
|
||||
: "bg-grayScale-100 text-grayScale-600 hover:bg-grayScale-200",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
||||
<SpinnerIcon className="h-8 w-8 text-brand-500" />
|
||||
<p className="text-sm text-grayScale-500">Loading app versions…</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-[8px] border border-dashed border-grayScale-200 py-16">
|
||||
<p className="text-sm font-medium text-grayScale-700">Could not load versions</p>
|
||||
<Button variant="outline" size="sm" className="rounded-[6px]" onClick={() => void load()}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-[8px] border border-dashed border-grayScale-200 py-16 text-center">
|
||||
<TabletSmartphone className="h-10 w-10 text-grayScale-300" />
|
||||
<p className="text-sm font-medium text-grayScale-700">
|
||||
{versions.length === 0 ? "No app versions yet" : "No versions match your filters"}
|
||||
</p>
|
||||
<p className="max-w-sm text-xs text-grayScale-500">
|
||||
{versions.length === 0
|
||||
? "Publish your first Android or iOS release to control learner update prompts."
|
||||
: "Try a different search or filter."}
|
||||
</p>
|
||||
{versions.length === 0 ? (
|
||||
<Button
|
||||
className="mt-1 rounded-[6px] bg-brand-500 text-white hover:bg-brand-600"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Publish version
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-0 w-full max-w-full overflow-x-auto rounded-[8px] border border-grayScale-100">
|
||||
<table className="w-full min-w-[640px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-grayScale-100 bg-grayScale-50/80 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Release</th>
|
||||
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Platform</th>
|
||||
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Update</th>
|
||||
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Min</th>
|
||||
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Status</th>
|
||||
<th className="min-w-[140px] px-3 py-3 sm:px-4">Notes</th>
|
||||
<th className="whitespace-nowrap px-3 py-3 sm:px-4">Published</th>
|
||||
<th className="sticky right-0 z-10 whitespace-nowrap bg-grayScale-50/95 px-3 py-3 text-right shadow-[-8px_0_12px_-8px_rgba(0,0,0,0.12)] backdrop-blur-sm sm:px-4">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-grayScale-50">
|
||||
{filtered.map((version) => (
|
||||
<tr key={version.id} className="group transition-colors hover:bg-grayScale-50/60">
|
||||
<td className="whitespace-nowrap px-3 py-3 sm:px-4 sm:py-4">
|
||||
<p className="font-semibold text-grayScale-900">
|
||||
{versionLabel(version)}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-grayScale-400">ID {version.id}</p>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3 sm:px-4 sm:py-4">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="inline-flex items-center gap-1.5 font-medium"
|
||||
>
|
||||
<PlatformIcon platform={version.platform} />
|
||||
{formatAppPlatform(version.platform)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3 sm:px-4 sm:py-4">
|
||||
<Badge variant={updateTypeBadgeVariant(version.update_type)}>
|
||||
{formatUpdateType(version.update_type)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3 font-mono text-xs text-grayScale-700 sm:px-4 sm:py-4">
|
||||
{version.min_supported_version_code}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3 sm:px-4 sm:py-4">
|
||||
<Badge variant={statusBadgeVariant(version.status)}>
|
||||
{formatVersionStatus(version.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="max-w-[180px] px-3 py-3 sm:px-4 sm:py-4">
|
||||
<p className="line-clamp-2 text-xs text-grayScale-600">
|
||||
{version.release_notes}
|
||||
</p>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3 text-grayScale-500 sm:px-4 sm:py-4">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||
<Calendar className="h-3.5 w-3.5 shrink-0" />
|
||||
{formatAppVersionCreatedAt(version.created_at)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="sticky right-0 z-10 whitespace-nowrap bg-white px-3 py-3 shadow-[-8px_0_12px_-8px_rgba(0,0,0,0.08)] group-hover:bg-grayScale-50/60 sm:px-4 sm:py-4">
|
||||
<div className="flex justify-end gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 rounded-[6px] p-0 text-grayScale-500 hover:text-brand-600"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={version.store_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Open store for ${versionLabel(version)}`}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 rounded-[6px] p-0 text-grayScale-500 hover:text-brand-600"
|
||||
aria-label={`Edit ${versionLabel(version)}`}
|
||||
onClick={() => setVersionToEdit(version)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 rounded-[6px] p-0 text-grayScale-500 hover:text-destructive"
|
||||
aria-label={`Delete ${versionLabel(version)}`}
|
||||
onClick={() => setVersionToDelete(version)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && totalCount > 0 ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-grayScale-100 pt-4">
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Showing {pageStart}–{pageEnd} of {totalCount}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-[6px]"
|
||||
disabled={!canPrev || loading}
|
||||
onClick={() => setOffset((o) => Math.max(0, o - PAGE_SIZE))}
|
||||
>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-[6px]"
|
||||
disabled={!canNext || loading}
|
||||
onClick={() => setOffset((o) => o + PAGE_SIZE)}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CreateAppVersionDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onCreated={handleCreated}
|
||||
/>
|
||||
|
||||
<EditAppVersionDialog
|
||||
version={versionToEdit}
|
||||
open={versionToEdit != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setVersionToEdit(null)
|
||||
}}
|
||||
onUpdated={handleUpdated}
|
||||
/>
|
||||
|
||||
<DeleteAppVersionDialog
|
||||
version={versionToDelete}
|
||||
open={versionToDelete != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setVersionToDelete(null)
|
||||
}}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
343
src/pages/settings/SubscriptionPlansTab.tsx
Normal file
343
src/pages/settings/SubscriptionPlansTab.tsx
Normal file
|
|
@ -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<SubscriptionPlan[]>([])
|
||||
const [query, setQuery] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all")
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [planToEdit, setPlanToEdit] = useState<SubscriptionPlan | null>(null)
|
||||
const [planToDelete, setPlanToDelete] = useState<SubscriptionPlan | null>(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 (
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2 min-w-0 w-full max-w-full space-y-6 duration-300">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-bold uppercase tracking-wider text-brand-500">
|
||||
Billing & catalog
|
||||
</p>
|
||||
<h2 className="text-lg font-bold text-grayScale-900">Subscription packages</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm text-grayScale-500">
|
||||
Manage learner subscription plans from{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-xs">GET /subscription-plans</code>
|
||||
. Create, edit, or remove packages for the learner checkout flow.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
className="shrink-0 rounded-[6px] bg-brand-500 font-semibold text-white hover:bg-brand-600"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New package
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="shrink-0 rounded-[6px]"
|
||||
disabled={loading}
|
||||
onClick={() => void load()}
|
||||
>
|
||||
<RefreshCw className={cn("mr-2 h-4 w-4", loading && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card className="overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
|
||||
<div className="h-1 bg-brand-500" />
|
||||
<CardContent className="flex items-center gap-4 p-5">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-brand-50 text-brand-600">
|
||||
<Package className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-grayScale-500">Total packages</p>
|
||||
<p className="text-2xl font-bold text-grayScale-900">{plans.length}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
|
||||
<div className="h-1 bg-mint-500" />
|
||||
<CardContent className="flex items-center gap-4 p-5">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-mint-50 text-mint-600">
|
||||
<CreditCard className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-grayScale-500">Active</p>
|
||||
<p className="text-2xl font-bold text-grayScale-900">{activeCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="overflow-hidden rounded-[8px] border border-grayScale-100 shadow-none">
|
||||
<div className="h-1 bg-grayScale-300" />
|
||||
<CardContent className="flex items-center gap-4 p-5">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-[8px] bg-grayScale-100 text-grayScale-500">
|
||||
<Tag className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-grayScale-500">Inactive</p>
|
||||
<p className="text-2xl font-bold text-grayScale-900">{plans.length - activeCount}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="min-w-0 rounded-[8px] border border-grayScale-100 shadow-none">
|
||||
<CardHeader className="border-b border-grayScale-50 pb-4">
|
||||
<CardTitle className="text-sm font-bold text-grayScale-900">All packages</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="min-w-0 space-y-4 p-4 sm:p-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-grayScale-400" />
|
||||
<Input
|
||||
className="rounded-[6px] pl-9"
|
||||
placeholder="Search by name, description, or category…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(
|
||||
[
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "active", label: "Active" },
|
||||
{ id: "inactive", label: "Inactive" },
|
||||
] as const
|
||||
).map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(tab.id)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1.5 text-xs font-semibold transition-colors",
|
||||
statusFilter === tab.id
|
||||
? "bg-brand-500 text-white"
|
||||
: "bg-grayScale-100 text-grayScale-600 hover:bg-grayScale-200",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
||||
<SpinnerIcon className="h-8 w-8 text-brand-500" />
|
||||
<p className="text-sm text-grayScale-500">Loading packages…</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-[8px] border border-dashed border-grayScale-200 py-16">
|
||||
<p className="text-sm font-medium text-grayScale-700">Could not load packages</p>
|
||||
<Button variant="outline" size="sm" className="rounded-[6px]" onClick={() => void load()}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-[8px] border border-dashed border-grayScale-200 py-16 text-center">
|
||||
<Package className="h-10 w-10 text-grayScale-300" />
|
||||
<p className="text-sm font-medium text-grayScale-700">
|
||||
{plans.length === 0 ? "No subscription packages yet" : "No packages match your filters"}
|
||||
</p>
|
||||
<p className="max-w-sm text-xs text-grayScale-500">
|
||||
{plans.length === 0
|
||||
? "Create your first package to offer paid access in the learner app."
|
||||
: "Try a different search or status filter."}
|
||||
</p>
|
||||
{plans.length === 0 ? (
|
||||
<Button
|
||||
className="mt-1 rounded-[6px] bg-brand-500 text-white hover:bg-brand-600"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create package
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-0 w-full max-w-full overflow-x-auto rounded-[8px] border border-grayScale-100">
|
||||
<table className="w-full min-w-[800px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-grayScale-100 bg-grayScale-50/80 text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
<th className="px-4 py-3">Package</th>
|
||||
<th className="px-4 py-3">Category</th>
|
||||
<th className="px-4 py-3">Duration</th>
|
||||
<th className="px-4 py-3">Price</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Created</th>
|
||||
<th className="px-4 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-grayScale-50">
|
||||
{filtered.map((plan) => (
|
||||
<tr key={plan.id} className="transition-colors hover:bg-grayScale-50/60">
|
||||
<td className="px-4 py-4">
|
||||
<p className="font-semibold text-grayScale-900">{plan.name}</p>
|
||||
<p className="mt-0.5 line-clamp-2 max-w-xs text-xs text-grayScale-500">
|
||||
{plan.description}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<Badge variant="secondary" className="font-medium">
|
||||
{formatPlanCategory(plan.category)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-grayScale-700">
|
||||
{formatPlanDuration(plan)}
|
||||
</td>
|
||||
<td className="px-4 py-4 font-semibold text-grayScale-900">
|
||||
{formatPlanPrice(plan)}
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<Badge variant={plan.is_active ? "success" : "secondary"}>
|
||||
{plan.is_active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-grayScale-500">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5 shrink-0" />
|
||||
{formatPlanCreatedAt(plan.created_at)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 rounded-[6px] p-0 text-grayScale-500 hover:text-brand-600"
|
||||
aria-label={`Edit ${plan.name}`}
|
||||
onClick={() => setPlanToEdit(plan)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 rounded-[6px] p-0 text-grayScale-500 hover:text-destructive"
|
||||
aria-label={`Delete ${plan.name}`}
|
||||
onClick={() => setPlanToDelete(plan)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CreateSubscriptionPlanDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onCreated={handleCreated}
|
||||
/>
|
||||
|
||||
<EditSubscriptionPlanDialog
|
||||
plan={planToEdit}
|
||||
open={planToEdit != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPlanToEdit(null)
|
||||
}}
|
||||
onUpdated={handleUpdated}
|
||||
/>
|
||||
|
||||
<DeleteSubscriptionPlanDialog
|
||||
plan={planToDelete}
|
||||
open={planToDelete != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPlanToDelete(null)
|
||||
}}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
321
src/pages/settings/components/CreateAppVersionDialog.tsx
Normal file
321
src/pages/settings/components/CreateAppVersionDialog.tsx
Normal file
|
|
@ -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<CreateAppVersionDraft>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto rounded-[12px] border border-grayScale-100 p-0">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
|
||||
<DialogTitle className="text-lg font-bold text-grayScale-900">
|
||||
New app version
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-500">
|
||||
Publishes a release via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-xs">POST /admin/app-versions</code>
|
||||
. Learners on older builds will see update prompts based on these rules.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Platform <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Select
|
||||
value={draft.platform}
|
||||
onChange={(e) => handlePlatformChange(e.target.value as AppPlatform)}
|
||||
className="rounded-[6px]"
|
||||
>
|
||||
{APP_PLATFORMS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Status
|
||||
</label>
|
||||
<Select
|
||||
value={draft.status}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, status: e.target.value as AppVersionStatus }))
|
||||
}
|
||||
className="rounded-[6px]"
|
||||
>
|
||||
{APP_VERSION_STATUSES.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Version name <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={draft.version_name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, version_name: e.target.value }))}
|
||||
placeholder="1.3.0"
|
||||
className="rounded-[6px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Version code <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={draft.version_code}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, version_code: e.target.value }))}
|
||||
placeholder="15"
|
||||
className="rounded-[6px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Update type
|
||||
</label>
|
||||
<Select
|
||||
value={draft.update_type}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, update_type: e.target.value as AppUpdateType }))
|
||||
}
|
||||
className="rounded-[6px]"
|
||||
>
|
||||
{APP_UPDATE_TYPES.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Min supported code <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={draft.min_supported_version_code}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, min_supported_version_code: e.target.value }))
|
||||
}
|
||||
placeholder="12"
|
||||
className="rounded-[6px]"
|
||||
required
|
||||
/>
|
||||
<p className="text-[11px] text-grayScale-500">
|
||||
Builds below this code are prompted to update
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Store URL <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={draft.store_url}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, store_url: e.target.value }))}
|
||||
placeholder="https://play.google.com/store/apps/details?id=…"
|
||||
className="rounded-[6px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Release notes <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
value={draft.release_notes}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, release_notes: e.target.value }))}
|
||||
placeholder="Critical security update and performance improvements."
|
||||
className="min-h-[96px] rounded-[6px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 border-t border-grayScale-100 px-6 py-4 sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-[6px]"
|
||||
disabled={saving}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-[6px] bg-brand-500 font-semibold text-white hover:bg-brand-600"
|
||||
>
|
||||
{saving ? (
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{saving ? "Publishing…" : "Publish version"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
308
src/pages/settings/components/CreateSubscriptionPlanDialog.tsx
Normal file
308
src/pages/settings/components/CreateSubscriptionPlanDialog.tsx
Normal file
|
|
@ -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<CreateSubscriptionPlanDraft>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto rounded-[12px] border border-grayScale-100 p-0">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
|
||||
<DialogTitle className="text-lg font-bold text-grayScale-900">
|
||||
New subscription package
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-500">
|
||||
Creates a plan via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-xs">POST /subscription-plans</code>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Package name <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
|
||||
placeholder="e.g. Monthly Premium"
|
||||
className="rounded-[6px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Description <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
value={draft.description}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, description: e.target.value }))}
|
||||
placeholder="What learners get with this package"
|
||||
className="min-h-[88px] rounded-[6px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Category
|
||||
</label>
|
||||
<Select
|
||||
value={draft.category}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, category: e.target.value as SubscriptionPlanCategory }))
|
||||
}
|
||||
className="rounded-[6px]"
|
||||
>
|
||||
{SUBSCRIPTION_PLAN_CATEGORIES.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Duration <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={draft.duration_value}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, duration_value: e.target.value }))}
|
||||
className="rounded-[6px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Duration unit
|
||||
</label>
|
||||
<Select
|
||||
value={draft.duration_unit}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
duration_unit: e.target.value as SubscriptionPlanDurationUnit,
|
||||
}))
|
||||
}
|
||||
className="rounded-[6px]"
|
||||
>
|
||||
{SUBSCRIPTION_DURATION_UNITS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Price <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={draft.price}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, price: e.target.value }))}
|
||||
placeholder="5.00"
|
||||
className="rounded-[6px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Currency
|
||||
</label>
|
||||
<Select
|
||||
value={draft.currency}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, currency: e.target.value }))}
|
||||
className="rounded-[6px]"
|
||||
>
|
||||
{SUBSCRIPTION_CURRENCIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer items-center justify-between gap-4 rounded-[8px] border border-grayScale-100 bg-grayScale-50/50 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-grayScale-800">Active package</p>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Inactive plans stay in the catalog but are hidden from checkout
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={draft.is_active}
|
||||
onClick={() => setDraft((d) => ({ ...d, is_active: !d.is_active }))}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
|
||||
draft.is_active ? "bg-brand-500" : "bg-grayScale-200",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform",
|
||||
draft.is_active ? "translate-x-5" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 border-t border-grayScale-100 px-6 py-4 sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-[6px]"
|
||||
disabled={saving}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-[6px] bg-brand-500 font-semibold text-white hover:bg-brand-600"
|
||||
>
|
||||
{saving ? (
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{saving ? "Creating…" : "Create package"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
98
src/pages/settings/components/DeleteAppVersionDialog.tsx
Normal file
98
src/pages/settings/components/DeleteAppVersionDialog.tsx
Normal file
|
|
@ -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 (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md rounded-[12px] border border-grayScale-100 sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-lg font-bold text-grayScale-900">
|
||||
<Trash2 className="h-5 w-5 shrink-0 text-destructive" aria-hidden />
|
||||
Delete app version?
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-left text-grayScale-600">
|
||||
This permanently removes the release record. Learners will no longer receive update
|
||||
prompts for this version entry. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{version ? (
|
||||
<div className="space-y-1 rounded-[8px] border border-grayScale-100 bg-grayScale-50 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-grayScale-900">{versionLabel(version)}</p>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
#{version.id} · {formatAppPlatform(version.platform)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-[6px]"
|
||||
disabled={deleting}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="rounded-[6px]"
|
||||
disabled={deleting || !version}
|
||||
onClick={() => void handleConfirm()}
|
||||
>
|
||||
{deleting ? "Deleting…" : "Delete version"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md rounded-[12px] border border-grayScale-100 sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-lg font-bold text-grayScale-900">
|
||||
<Trash2 className="h-5 w-5 shrink-0 text-destructive" aria-hidden />
|
||||
Delete subscription package?
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-left text-grayScale-600">
|
||||
This permanently removes the package from the catalog. Learners will no longer be
|
||||
able to purchase it. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{plan ? (
|
||||
<div className="space-y-1 rounded-[8px] border border-grayScale-100 bg-grayScale-50 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-grayScale-900">{plan.name}</p>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
#{plan.id} · {formatPlanPrice(plan)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-[6px]"
|
||||
disabled={deleting}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="rounded-[6px]"
|
||||
disabled={deleting || !plan}
|
||||
onClick={() => void handleConfirm()}
|
||||
>
|
||||
{deleting ? "Deleting…" : "Delete package"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
277
src/pages/settings/components/EditAppVersionDialog.tsx
Normal file
277
src/pages/settings/components/EditAppVersionDialog.tsx
Normal file
|
|
@ -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<EditDraft | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto rounded-[12px] border border-grayScale-100 p-0">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
|
||||
<DialogTitle className="text-lg font-bold text-grayScale-900">
|
||||
Edit app version
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-500">
|
||||
Updates via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-xs">
|
||||
PUT /admin/app-versions/{version?.id ?? ":id"}
|
||||
</code>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{draft && version ? (
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-[8px] border border-grayScale-100 bg-grayScale-50/50 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Release
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm font-semibold text-grayScale-900">
|
||||
{versionLabel(version)}
|
||||
</p>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Platform and version identifiers cannot be changed
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">{formatAppPlatform(version.platform)}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Update type
|
||||
</label>
|
||||
<Select
|
||||
value={draft.update_type}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => d && { ...d, update_type: e.target.value as AppUpdateType })
|
||||
}
|
||||
className="rounded-[6px]"
|
||||
>
|
||||
{APP_UPDATE_TYPES.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Status
|
||||
</label>
|
||||
<Select
|
||||
value={draft.status}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => d && { ...d, status: e.target.value as AppVersionStatus })
|
||||
}
|
||||
className="rounded-[6px]"
|
||||
>
|
||||
{APP_VERSION_STATUSES.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Min supported code <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={draft.min_supported_version_code}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => d && { ...d, min_supported_version_code: e.target.value })
|
||||
}
|
||||
className="rounded-[6px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Store URL <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={draft.store_url}
|
||||
onChange={(e) => setDraft((d) => d && { ...d, store_url: e.target.value })}
|
||||
className="rounded-[6px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Release notes <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
value={draft.release_notes}
|
||||
onChange={(e) => setDraft((d) => d && { ...d, release_notes: e.target.value })}
|
||||
className="min-h-[96px] rounded-[6px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="gap-2 border-t border-grayScale-100 px-6 py-4 sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-[6px]"
|
||||
disabled={saving}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving || !draft}
|
||||
className="rounded-[6px] bg-brand-500 font-semibold text-white hover:bg-brand-600"
|
||||
>
|
||||
{saving ? (
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
319
src/pages/settings/components/EditSubscriptionPlanDialog.tsx
Normal file
319
src/pages/settings/components/EditSubscriptionPlanDialog.tsx
Normal file
|
|
@ -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<EditDraft | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto rounded-[12px] border border-grayScale-100 p-0">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader className="border-b border-grayScale-100 px-6 py-5">
|
||||
<DialogTitle className="text-lg font-bold text-grayScale-900">
|
||||
Edit subscription package
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-grayScale-500">
|
||||
Updates via{" "}
|
||||
<code className="rounded bg-grayScale-100 px-1 text-xs">
|
||||
PUT /subscription-plans/{plan?.id ?? ":id"}
|
||||
</code>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{draft && plan ? (
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
<div className="flex items-center justify-between gap-3 rounded-[8px] border border-grayScale-100 bg-grayScale-50/50 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Category
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-grayScale-500">
|
||||
Category cannot be changed after creation
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">{formatPlanCategory(plan.category)}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Package name <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft((d) => d && { ...d, name: e.target.value })}
|
||||
className="rounded-[6px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Description <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
value={draft.description}
|
||||
onChange={(e) => setDraft((d) => d && { ...d, description: e.target.value })}
|
||||
className="min-h-[88px] rounded-[6px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Duration <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={draft.duration_value}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => d && { ...d, duration_value: e.target.value })
|
||||
}
|
||||
className="rounded-[6px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Duration unit
|
||||
</label>
|
||||
<Select
|
||||
value={draft.duration_unit}
|
||||
onChange={(e) =>
|
||||
setDraft((d) =>
|
||||
d
|
||||
? {
|
||||
...d,
|
||||
duration_unit: e.target.value as SubscriptionPlanDurationUnit,
|
||||
}
|
||||
: d,
|
||||
)
|
||||
}
|
||||
className="rounded-[6px]"
|
||||
>
|
||||
{SUBSCRIPTION_DURATION_UNITS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Price <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={draft.price}
|
||||
onChange={(e) => setDraft((d) => d && { ...d, price: e.target.value })}
|
||||
className="rounded-[6px]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
Currency
|
||||
</label>
|
||||
<Select
|
||||
value={draft.currency}
|
||||
onChange={(e) => setDraft((d) => d && { ...d, currency: e.target.value })}
|
||||
className="rounded-[6px]"
|
||||
>
|
||||
{SUBSCRIPTION_CURRENCIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer items-center justify-between gap-4 rounded-[8px] border border-grayScale-100 bg-grayScale-50/50 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-grayScale-800">Active package</p>
|
||||
<p className="text-xs text-grayScale-500">
|
||||
Inactive plans stay in the catalog but are hidden from checkout
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={draft.is_active}
|
||||
onClick={() => setDraft((d) => d && { ...d, is_active: !d.is_active })}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors",
|
||||
draft.is_active ? "bg-brand-500" : "bg-grayScale-200",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform",
|
||||
draft.is_active ? "translate-x-5" : "translate-x-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="gap-2 border-t border-grayScale-100 px-6 py-4 sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-[6px]"
|
||||
disabled={saving}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving || !draft}
|
||||
className="rounded-[6px] bg-brand-500 font-semibold text-white hover:bg-brand-600"
|
||||
>
|
||||
{saving ? (
|
||||
<SpinnerIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
75
src/pages/settings/components/ThemeModePreview.tsx
Normal file
75
src/pages/settings/components/ThemeModePreview.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-14 w-full overflow-hidden rounded-md border border-grayScale-200 shadow-inner",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 grid grid-cols-2">
|
||||
<div className="flex flex-col gap-1 bg-[#f5f5f5] p-1.5">
|
||||
<div className="h-1.5 w-8 rounded-sm bg-white shadow-sm" />
|
||||
<div className="mt-auto h-4 rounded-sm bg-white shadow-sm" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 bg-[#12121a] p-1.5">
|
||||
<div className="h-1.5 w-8 rounded-sm bg-[#2e2e3a]" />
|
||||
<div className="mt-auto h-4 rounded-sm bg-[#1c1c24]" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="absolute bottom-1 right-1 rounded bg-black/60 px-1 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-white">
|
||||
{systemResolved}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isDark = variant === "dark"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-14 w-full flex-col gap-1 rounded-md border p-1.5 shadow-inner",
|
||||
isDark
|
||||
? "border-[#2e2e3a] bg-[#12121a]"
|
||||
: "border-grayScale-200 bg-[#f5f5f5]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-1.5 w-10 rounded-sm",
|
||||
isDark ? "bg-[#2e2e3a]" : "bg-white shadow-sm",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-1 gap-1">
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 rounded-sm",
|
||||
isDark ? "bg-[#1c1c24]" : "bg-white shadow-sm",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"w-4 rounded-sm",
|
||||
isDark ? "bg-[#9E2891]" : "bg-[#9E2891]",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,7 +426,11 @@ export function UserLogPage() {
|
|||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<ActorHoverCard
|
||||
actorId={log.actor_id}
|
||||
actorRole={log.actor_role}
|
||||
>
|
||||
<div className="flex items-center gap-2 rounded-lg px-1 py-0.5 transition-colors hover:bg-grayScale-50">
|
||||
<div className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-grayScale-100 text-grayScale-500">
|
||||
<User className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
|
|
@ -440,6 +445,7 @@ export function UserLogPage() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ActorHoverCard>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
|
|
|
|||
251
src/pages/user-log/components/ActorHoverCard.tsx
Normal file
251
src/pages/user-log/components/ActorHoverCard.tsx
Normal file
|
|
@ -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<HTMLDivElement>(null)
|
||||
const showTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | 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<string | null>(null)
|
||||
const [profile, setProfile] = useState<ActorProfile | null>(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 (
|
||||
<>
|
||||
<div
|
||||
ref={triggerRef}
|
||||
className="inline-flex cursor-default"
|
||||
onMouseEnter={handleEnter}
|
||||
onMouseLeave={handleLeave}
|
||||
onFocus={handleEnter}
|
||||
onBlur={handleLeave}
|
||||
aria-describedby={open ? tooltipId : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
id={tooltipId}
|
||||
role="tooltip"
|
||||
className={cn(
|
||||
"fixed z-[100] w-72 -translate-y-1/2 rounded-xl border border-grayScale-100 bg-white p-4 shadow-lg transition-all duration-200 ease-out",
|
||||
visible ? "translate-x-0 opacity-100" : "translate-x-1 opacity-0",
|
||||
)}
|
||||
style={{ top: position.top, left: position.left }}
|
||||
onMouseEnter={handleEnter}
|
||||
onMouseLeave={handleLeave}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-sm text-grayScale-500">
|
||||
<SpinnerIcon className="h-5 w-5 text-brand-500" />
|
||||
Loading…
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="py-4 text-center text-sm text-grayScale-500">{error}</p>
|
||||
) : profile ? (
|
||||
<ActorProfileContent profile={profile} />
|
||||
) : null}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ActorProfileContent({ profile }: { profile: ActorProfile }) {
|
||||
const isTeam = profile.kind === "team"
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-10 w-10 shrink-0 place-items-center rounded-lg",
|
||||
isTeam ? "bg-brand-50 text-brand-600" : "bg-mint-50 text-mint-700",
|
||||
)}
|
||||
>
|
||||
{isTeam ? <Shield className="h-5 w-5" /> : <User className="h-5 w-5" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-grayScale-900">{profile.name}</p>
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-grayScale-400">
|
||||
{isTeam ? "Team member" : "Learner"} · #{profile.id}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="space-y-2 text-sm">
|
||||
<DetailRow icon={<Mail className="h-3.5 w-3.5" />} label="Email" value={profile.email} />
|
||||
<DetailRow label="Role" value={profile.roleLabel} />
|
||||
<DetailRow label="Status" value={profile.status} capitalize />
|
||||
<DetailRow
|
||||
label="Email verified"
|
||||
value={profile.emailVerified ? "Yes" : "No"}
|
||||
/>
|
||||
{profile.kind === "user" ? (
|
||||
<>
|
||||
<DetailRow label="Location" value={`${profile.region}, ${profile.country}`} />
|
||||
<DetailRow
|
||||
label="Last login"
|
||||
value={
|
||||
profile.lastLogin ? formatActorDate(profile.lastLogin) : "Never"
|
||||
}
|
||||
/>
|
||||
<DetailRow label="Subscription" value={profile.subscriptionStatus} />
|
||||
</>
|
||||
) : null}
|
||||
<DetailRow label="Joined" value={formatActorDate(profile.createdAt)} />
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailRow({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
capitalize,
|
||||
}: {
|
||||
icon?: ReactNode
|
||||
label: string
|
||||
value: string
|
||||
capitalize?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{icon ? (
|
||||
<span className="mt-0.5 shrink-0 text-grayScale-400">{icon}</span>
|
||||
) : (
|
||||
<span className="w-3.5 shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<dt className="text-[10px] font-bold uppercase tracking-wider text-grayScale-400">
|
||||
{label}
|
||||
</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
"truncate font-medium text-grayScale-700",
|
||||
capitalize && "capitalize",
|
||||
)}
|
||||
title={value}
|
||||
>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
|||
58
src/types/app-version.types.ts
Normal file
58
src/types/app-version.types.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
61
src/types/payment.types.ts
Normal file
61
src/types/payment.types.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user