| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374 |
- import * as z from 'zod'
- import type { Resolver } from 'react-hook-form'
- import { zodResolver } from '@hookform/resolvers/zod'
- import { RotateCcw } from 'lucide-react'
- import { useTranslation } from 'react-i18next'
- import { DEFAULT_CURRENCY_CONFIG } from '@/stores/system-config-store'
- 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 { Switch } from '@/components/ui/switch'
- import { FormDirtyIndicator } from '../components/form-dirty-indicator'
- import { FormNavigationGuard } from '../components/form-navigation-guard'
- import { SettingsSection } from '../components/settings-section'
- import { useSettingsForm } from '../hooks/use-settings-form'
- import { useUpdateOption } from '../hooks/use-update-option'
- const createPricingSchema = (t: (key: string) => string) =>
- z
- .object({
- QuotaPerUnit: z.coerce.number().min(0, t('Value must be at least 0')),
- USDExchangeRate: z.coerce
- .number()
- .min(0.0001, t('Exchange rate must be greater than 0')),
- DisplayInCurrencyEnabled: z.boolean(),
- DisplayTokenStatEnabled: z.boolean(),
- general_setting: z.object({
- quota_display_type: z.enum(['USD', 'CNY', 'TOKENS', 'CUSTOM']),
- custom_currency_symbol: z.string().max(8).optional(),
- custom_currency_exchange_rate: z.coerce
- .number()
- .min(0.0001, t('Exchange rate must be greater than 0'))
- .optional(),
- }),
- })
- .superRefine((data, ctx) => {
- const displayType = data.general_setting.quota_display_type
- if (displayType === 'CUSTOM') {
- if (!data.general_setting.custom_currency_symbol?.trim()) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ['general_setting', 'custom_currency_symbol'],
- message: t('Custom currency symbol is required'),
- })
- }
- if (data.general_setting.custom_currency_exchange_rate == null) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- path: ['general_setting', 'custom_currency_exchange_rate'],
- message: t('Exchange rate is required'),
- })
- }
- }
- })
- type PricingFormValues = z.infer<ReturnType<typeof createPricingSchema>>
- type PricingSectionProps = {
- defaultValues: PricingFormValues
- }
- export function PricingSection({ defaultValues }: PricingSectionProps) {
- const { t } = useTranslation()
- const updateOption = useUpdateOption()
- const pricingSchema = createPricingSchema(t)
- const { form, handleSubmit, handleReset, isDirty, isSubmitting } =
- useSettingsForm<PricingFormValues>({
- resolver: zodResolver(pricingSchema) as Resolver<
- PricingFormValues,
- unknown,
- PricingFormValues
- >,
- defaultValues,
- onSubmit: async (_data, changedFields) => {
- for (const [key, value] of Object.entries(changedFields)) {
- if (value === undefined || value === null) continue
- if (typeof value === 'object') continue
- let serialized: string | boolean = value as string | boolean
- if (typeof value === 'boolean') {
- serialized = String(value)
- } else if (typeof value === 'number') {
- serialized = Number.isFinite(value) ? String(value) : '0'
- }
- await updateOption.mutateAsync({
- key,
- value: serialized,
- })
- }
- },
- })
- const displayType = form.watch('general_setting.quota_display_type') ?? 'USD'
- const displayInCurrencyEnabled = form.watch('DisplayInCurrencyEnabled')
- const showTokensOnlyOption = displayType === 'TOKENS'
- const showQuotaPerUnit =
- displayType === 'TOKENS' ||
- defaultValues.QuotaPerUnit !== DEFAULT_CURRENCY_CONFIG.quotaPerUnit
- const showDisplayInCurrencyOption = displayInCurrencyEnabled === false
- return (
- <>
- <FormNavigationGuard when={isDirty} />
- <SettingsSection
- title={t('Pricing & Display')}
- description={t('Configure pricing model and display options')}
- >
- <Form {...form}>
- <form onSubmit={handleSubmit} className='space-y-6'>
- <FormDirtyIndicator isDirty={isDirty} />
- {showQuotaPerUnit && (
- <FormField
- control={form.control}
- name='QuotaPerUnit'
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t('Quota Per Unit')}</FormLabel>
- <FormControl>
- <Input
- type='number'
- step='0.01'
- value={field.value as number}
- disabled
- name={field.name}
- onBlur={field.onBlur}
- ref={field.ref}
- />
- </FormControl>
- <FormDescription>
- {t('Number of tokens per unit quota')}
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
- )}
- <FormField
- control={form.control}
- name='general_setting.quota_display_type'
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t('Display Mode')}</FormLabel>
- <Select
- items={[
- { value: 'USD', label: t('USD') },
- { value: 'CNY', label: t('CNY') },
- { value: 'CUSTOM', label: t('Custom Currency') },
- { value: 'TOKENS', label: t('Tokens Only') },
- ]}
- value={field.value}
- onValueChange={field.onChange}
- >
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder={t('Select display mode')} />
- </SelectTrigger>
- </FormControl>
- <SelectContent alignItemWithTrigger={false}>
- <SelectGroup>
- <SelectItem value='USD'>{t('USD')}</SelectItem>
- <SelectItem value='CNY'>{t('CNY')}</SelectItem>
- <SelectItem value='CUSTOM'>
- {t('Custom Currency')}
- </SelectItem>
- {showTokensOnlyOption && (
- <SelectItem value='TOKENS'>
- {t('Tokens Only')}
- </SelectItem>
- )}
- </SelectGroup>
- </SelectContent>
- </Select>
- <FormDescription>
- {t('Choose how quota values are shown to users')}
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
- {displayType !== 'TOKENS' && (
- <FormField
- control={form.control}
- name='USDExchangeRate'
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- {displayType === 'CNY'
- ? t('CNY per USD')
- : displayType === 'USD'
- ? t('USD Exchange Rate')
- : t('USD Exchange Rate')}
- </FormLabel>
- <FormControl>
- <Input
- type='number'
- step='0.01'
- value={field.value as number}
- onChange={(e) => field.onChange(e.target.valueAsNumber)}
- name={field.name}
- onBlur={field.onBlur}
- ref={field.ref}
- />
- </FormControl>
- <FormDescription>
- {t(
- 'Real exchange rate between USD and your payment gateway currency'
- )}
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
- )}
- {displayType === 'CUSTOM' && (
- <div className='grid gap-4 sm:grid-cols-2'>
- <FormField
- control={form.control}
- name='general_setting.custom_currency_symbol'
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t('Custom Currency Symbol')}</FormLabel>
- <FormControl>
- <Input
- type='text'
- value={field.value ?? ''}
- onChange={field.onChange}
- name={field.name}
- onBlur={field.onBlur}
- ref={field.ref}
- maxLength={8}
- placeholder={t('e.g. ¥ or HK$')}
- />
- </FormControl>
- <FormDescription>
- {t('Prefix used when displaying prices')}
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
- <FormField
- control={form.control}
- name='general_setting.custom_currency_exchange_rate'
- render={({ field }) => (
- <FormItem>
- <FormLabel>{t('Units per USD')}</FormLabel>
- <FormControl>
- <Input
- type='number'
- step='0.01'
- value={field.value ?? ''}
- onChange={(e) =>
- field.onChange(
- e.target.value === ''
- ? undefined
- : e.target.valueAsNumber
- )
- }
- name={field.name}
- onBlur={field.onBlur}
- ref={field.ref}
- placeholder={t('e.g. 8 means 1 USD = 8 units')}
- />
- </FormControl>
- <FormDescription>
- {t('Conversion rate from USD to your custom currency')}
- </FormDescription>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
- )}
- {showDisplayInCurrencyOption && (
- <FormField
- control={form.control}
- name='DisplayInCurrencyEnabled'
- render={({ field }) => (
- <FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
- <div className='space-y-0.5'>
- <FormLabel className='text-base'>
- {t('Display in Currency')}
- </FormLabel>
- <FormDescription>
- {displayType === 'TOKENS'
- ? t(
- 'Tokens-only mode will show raw quota values regardless of this toggle.'
- )
- : t('Show prices in currency instead of quota.')}
- </FormDescription>
- </div>
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItem>
- )}
- />
- )}
- <FormField
- control={form.control}
- name='DisplayTokenStatEnabled'
- render={({ field }) => (
- <FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
- <div className='space-y-0.5'>
- <FormLabel className='text-base'>
- {t('Display Token Statistics')}
- </FormLabel>
- <FormDescription>
- {t('Show token usage statistics in the UI')}
- </FormDescription>
- </div>
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItem>
- )}
- />
- <div className='flex gap-2'>
- <Button
- type='submit'
- disabled={updateOption.isPending || isSubmitting}
- >
- {updateOption.isPending ? t('Saving...') : t('Save Changes')}
- </Button>
- <Button
- type='button'
- variant='outline'
- onClick={handleReset}
- disabled={!isDirty || updateOption.isPending || isSubmitting}
- >
- <RotateCcw className='mr-2 h-4 w-4' />
- {t('Reset')}
- </Button>
- </div>
- </form>
- </Form>
- </SettingsSection>
- </>
- )
- }
|