550 lines
19 KiB
TypeScript
Vendored
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>
|
|
)
|
|
}
|