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>
322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|