Yimaru-Admin/src/pages/settings/components/CreateAppVersionDialog.tsx
Yared Yemane 2c3f0da6f7 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>
2026-05-29 06:54:58 -07:00

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>
)
}