Files
chaos-api/web/default/src/features/subscriptions/components/subscriptions-mutate-drawer.tsx
T
t0ng7u 583da45296 refactor(ui): Improve usage log filter responsiveness and mobile UX
Refactor the usage log filter toolbar into a shared reusable component for common, drawing, and task logs. Optimize desktop filters with a responsive grid, move secondary filters into a mobile drawer, standardize filter typography, remove redundant filter icons, and add the missing i18n translations for the new drawer description.
2026-05-25 05:35:44 +08:00

739 lines
26 KiB
TypeScript
Vendored
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
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 {
SideDrawerSection,
sideDrawerContentClassName,
sideDrawerFooterClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
sideDrawerSwitchItemClassName,
} from '@/components/drawer-layout'
import {
createPlan,
updatePlan,
getGroups,
createWaffoPancakeSubscriptionProduct,
listWaffoPancakeSubscriptionProductOptions,
} 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 [creatingPancakeProduct, setCreatingPancakeProduct] = useState(false)
const [pancakeProducts, setPancakeProducts] = useState<
{ id: string; name: string; status: 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(() => {})
// Best-effort — empty list still lets the operator use "+ Create".
listWaffoPancakeSubscriptionProductOptions()
.then((res) => {
if (
res.message === 'success' &&
typeof res.data === 'object' &&
res.data &&
Array.isArray((res.data as { products?: unknown }).products)
) {
setPancakeProducts(
(res.data as { products: typeof pancakeProducts }).products
)
} else {
setPancakeProducts([])
}
})
.catch(() => setPancakeProducts([]))
}
}, [open, currentRow, form])
const durationUnit = form.watch('duration_unit')
const resetPeriod = form.watch('quota_reset_period')
// Gate "+ Create on Pancake" on the same checks the mint handler runs.
const watchedTitle = form.watch('title')
const watchedPrice = form.watch('price_amount')
const pancakeCreateReady =
typeof watchedTitle === 'string' &&
watchedTitle.trim().length > 0 &&
Number(watchedPrice ?? 0) > 0
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)
}
}
// Mints a Pancake OnetimeProduct (not SubscriptionProduct — see
// controller) using persisted creds + the form's title/price, then
// pins the returned PROD_ ID into the form field.
const handleCreatePancakeProduct = async () => {
const title = form.getValues('title').trim()
const priceAmount = Number(form.getValues('price_amount') || 0)
if (!title) {
toast.error(t('Plan title is required'))
return
}
if (priceAmount <= 0) {
toast.error(t('Plan price must be greater than zero'))
return
}
setCreatingPancakeProduct(true)
try {
const res = await createWaffoPancakeSubscriptionProduct({
name: title,
amount: priceAmount.toFixed(2),
})
if (
res.message === 'success' &&
typeof res.data === 'object' &&
res.data
) {
const created = res.data as { product_id: string; product_name: string }
form.setValue('waffo_pancake_product_id', created.product_id, {
shouldDirty: true,
})
// Refetch from GraphQL so the dropdown reflects authoritative state.
try {
const refresh = await listWaffoPancakeSubscriptionProductOptions()
if (
refresh.message === 'success' &&
typeof refresh.data === 'object' &&
refresh.data &&
Array.isArray((refresh.data as { products?: unknown }).products)
) {
setPancakeProducts(
(refresh.data as { products: typeof pancakeProducts }).products
)
}
} catch {
// Best-effort — form value already points at the new product;
// raw-ID fallback covers the missing label.
}
toast.success(
`${t('Waffo Pancake product created')}: ${created.product_id}`
)
} else {
const reason = typeof res.data === 'string' ? res.data : undefined
toast.error(
reason
? `${t('Waffo Pancake product creation failed')}: ${reason}`
: t('Waffo Pancake product creation failed')
)
}
} catch (err) {
toast.error(
`${t('Waffo Pancake product creation failed')}: ${err instanceof Error ? err.message : String(err)}`
)
} finally {
setCreatingPancakeProduct(false)
}
}
const durationUnitOpts = getDurationUnitOptions(t)
const resetPeriodOpts = getResetPeriodOptions(t)
return (
<Sheet
open={open}
onOpenChange={(v) => {
onOpenChange(v)
if (!v) {
form.reset()
}
}}
>
<SheetContent className={sideDrawerContentClassName('sm:max-w-[600px]')}>
<SheetHeader className={sideDrawerHeaderClassName()}>
<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={sideDrawerFormClassName()}
>
{/* Basic Info */}
<SideDrawerSection>
<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('Received amount')}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
min={0}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
}
/>
</FormControl>
<FormDescription>
{t(
'0 means unlimited. The value is converted to quota units when saved.'
)}
</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={sideDrawerSwitchItemClassName()}>
<FormLabel className='!mt-0'>
{t('Enabled Status')}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</SideDrawerSection>
{/* Duration Settings */}
<SideDrawerSection>
<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>
</SideDrawerSection>
{/* Quota Reset */}
<SideDrawerSection>
<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>
</SideDrawerSection>
{/* Payment Config */}
<SideDrawerSection>
<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>
)}
/>
<FormField
control={form.control}
name='waffo_pancake_product_id'
render={({ field }) => {
// Raw-ID fallback for IDs not yet in the catalog.
const items = pancakeProducts.map((p) => ({
value: p.id,
label: `${p.name} (${p.id})`,
}))
if (
field.value &&
!pancakeProducts.some((p) => p.id === field.value)
) {
items.push({ value: field.value, label: field.value })
}
return (
<FormItem>
<FormLabel>Waffo Pancake Product ID</FormLabel>
<div className='flex gap-2'>
<Select
items={items}
value={field.value || ''}
onValueChange={(v) => field.onChange(v)}
disabled={items.length === 0}
>
<SelectTrigger className='w-full flex-1'>
<SelectValue placeholder={t('Select a product')} />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type='button'
variant='outline'
onClick={handleCreatePancakeProduct}
disabled={
creatingPancakeProduct || !pancakeCreateReady
}
className='shrink-0'
>
{creatingPancakeProduct
? t('Creating...')
: `+ ${t('Create')}`}
</Button>
</div>
<FormDescription>
{t(
'Creates a Pancake product in the saved store using this plans title and price. Requires Waffo Pancake to be fully configured in Payment settings first.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)
}}
/>
</SideDrawerSection>
</form>
</Form>
<SheetFooter className={sideDrawerFooterClassName()}>
<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>
)
}