new-api/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx

550 lines
19 KiB
TypeScript
Vendored

import { useEffect, useState } from 'react'
import { useForm, type Resolver } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { CalendarClock, CreditCard, RefreshCw, Settings2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { Switch } from '@/components/ui/switch'
import { createPlan, updatePlan, getGroups } from '../api'
import { getDurationUnitOptions, getResetPeriodOptions } from '../constants'
import {
getPlanFormSchema,
PLAN_FORM_DEFAULTS,
planToFormValues,
formValuesToPlanPayload,
type PlanFormValues,
} from '../lib'
import type { PlanRecord } from '../types'
import { useSubscriptions } from './subscriptions-provider'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
currentRow?: PlanRecord
}
export function SubscriptionsMutateDrawer({
open,
onOpenChange,
currentRow,
}: Props) {
const { t } = useTranslation()
const isEdit = !!currentRow?.plan?.id
const { triggerRefresh } = useSubscriptions()
const [isSubmitting, setIsSubmitting] = useState(false)
const [groupOptions, setGroupOptions] = useState<string[]>([])
const schema = getPlanFormSchema(t)
const form = useForm<PlanFormValues>({
resolver: zodResolver(schema) as unknown as Resolver<PlanFormValues>,
defaultValues: PLAN_FORM_DEFAULTS,
})
useEffect(() => {
if (open) {
if (currentRow?.plan) {
form.reset(planToFormValues(currentRow.plan))
} else {
form.reset(PLAN_FORM_DEFAULTS)
}
getGroups()
.then((res) => {
if (res.success) setGroupOptions(res.data || [])
})
.catch(() => {})
}
}, [open, currentRow, form])
const durationUnit = form.watch('duration_unit')
const resetPeriod = form.watch('quota_reset_period')
const onSubmit = async (values: PlanFormValues) => {
setIsSubmitting(true)
try {
const payload = formValuesToPlanPayload(values)
if (isEdit && currentRow?.plan?.id) {
const res = await updatePlan(currentRow.plan.id, payload)
if (res.success) {
toast.success(t('Update succeeded'))
onOpenChange(false)
triggerRefresh()
}
} else {
const res = await createPlan(payload)
if (res.success) {
toast.success(t('Create succeeded'))
onOpenChange(false)
triggerRefresh()
}
}
} catch {
toast.error(t('Request failed'))
} finally {
setIsSubmitting(false)
}
}
const durationUnitOpts = getDurationUnitOptions(t)
const resetPeriodOpts = getResetPeriodOptions(t)
return (
<Sheet
open={open}
onOpenChange={(v) => {
onOpenChange(v)
if (!v) {
form.reset()
}
}}
>
<SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'>
<SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
<SheetTitle>
{isEdit ? t('Update plan info') : t('Create new subscription plan')}
</SheetTitle>
<SheetDescription>
{isEdit
? t('Modify existing subscription plan configuration')
: t(
'Fill in the following info to create a new subscription plan'
)}
</SheetDescription>
</SheetHeader>
<Form {...form}>
<form
id='subscription-form'
onSubmit={form.handleSubmit(onSubmit)}
className='flex-1 space-y-4 overflow-y-auto px-3 py-3 pb-4 sm:space-y-6 sm:px-4'
>
{/* Basic Info */}
<div className='space-y-4'>
<h3 className='flex items-center gap-2 text-sm font-medium'>
<Settings2 className='h-4 w-4' />
{t('Basic Info')}
</h3>
<FormField
control={form.control}
name='title'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Plan Title')}</FormLabel>
<FormControl>
<Input {...field} placeholder={t('e.g. Basic Plan')} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='subtitle'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Plan Subtitle')}</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t('e.g. Suitable for light usage')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
<FormField
control={form.control}
name='price_amount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Actual Amount')}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
step='0.01'
min={0}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='total_amount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Total Quota')}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
min={0}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
}
/>
</FormControl>
<FormDescription>
{t('0 means unlimited')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
<FormField
control={form.control}
name='upgrade_group'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Upgrade Group')}</FormLabel>
<Select
items={[
{ value: '__none__', label: t('No Upgrade') },
...groupOptions.map((g) => ({ value: g, label: g })),
]}
onValueChange={(v) =>
field.onChange(v === '__none__' ? '' : v)
}
value={field.value || ''}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('No Upgrade')} />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='__none__'>
{t('No Upgrade')}
</SelectItem>
{groupOptions.map((g) => (
<SelectItem key={g} value={g}>
{g}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='max_purchase_per_user'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Purchase Limit')}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
min={0}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
/>
</FormControl>
<FormDescription>
{t('0 means unlimited')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
<FormField
control={form.control}
name='sort_order'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Sort Order')}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center gap-2 pt-8'>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className='!mt-0'>
{t('Enabled Status')}
</FormLabel>
</FormItem>
)}
/>
</div>
</div>
{/* Duration Settings */}
<div className='space-y-4'>
<h3 className='flex items-center gap-2 text-sm font-medium'>
<CalendarClock className='h-4 w-4' />
{t('Duration Settings')}
</h3>
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
<FormField
control={form.control}
name='duration_unit'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Duration Unit')}</FormLabel>
<Select
items={[
...durationUnitOpts.map((o) => ({
value: o.value,
label: o.label,
})),
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{durationUnitOpts.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{durationUnit === 'custom' ? (
<FormField
control={form.control}
name='custom_seconds'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Custom Seconds')}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
min={1}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
) : (
<FormField
control={form.control}
name='duration_value'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Duration Value')}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
min={1}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</div>
{/* Quota Reset */}
<div className='space-y-4'>
<h3 className='flex items-center gap-2 text-sm font-medium'>
<RefreshCw className='h-4 w-4' />
{t('Quota Reset')}
</h3>
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
<FormField
control={form.control}
name='quota_reset_period'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Reset Cycle')}</FormLabel>
<Select
items={[
...resetPeriodOpts.map((o) => ({
value: o.value,
label: o.label,
})),
]}
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{resetPeriodOpts.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='quota_reset_custom_seconds'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Custom Seconds')}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
min={0}
disabled={resetPeriod !== 'custom'}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 0)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{/* Payment Config */}
<div className='space-y-4'>
<h3 className='flex items-center gap-2 text-sm font-medium'>
<CreditCard className='h-4 w-4' />
{t('Third-party Payment Config')}
</h3>
<FormField
control={form.control}
name='stripe_price_id'
render={({ field }) => (
<FormItem>
<FormLabel>Stripe Price ID</FormLabel>
<FormControl>
<Input {...field} placeholder='price_...' />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='creem_product_id'
render={({ field }) => (
<FormItem>
<FormLabel>Creem Product ID</FormLabel>
<FormControl>
<Input {...field} placeholder='prod_...' />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
<SheetFooter className='grid grid-cols-2 gap-2 border-t px-4 py-3 sm:flex sm:px-6 sm:py-4'>
<SheetClose render={<Button variant='outline' />}>
{t('Close')}
</SheetClose>
<Button
form='subscription-form'
type='submit'
disabled={isSubmitting}
>
{isSubmitting ? t('Saving...') : t('Save changes')}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}