pricing-section.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import * as z from 'zod'
  2. import type { Resolver } from 'react-hook-form'
  3. import { zodResolver } from '@hookform/resolvers/zod'
  4. import { RotateCcw } from 'lucide-react'
  5. import { useTranslation } from 'react-i18next'
  6. import { DEFAULT_CURRENCY_CONFIG } from '@/stores/system-config-store'
  7. import { Button } from '@/components/ui/button'
  8. import {
  9. Form,
  10. FormControl,
  11. FormDescription,
  12. FormField,
  13. FormItem,
  14. FormLabel,
  15. FormMessage,
  16. } from '@/components/ui/form'
  17. import { Input } from '@/components/ui/input'
  18. import {
  19. Select,
  20. SelectContent,
  21. SelectGroup,
  22. SelectItem,
  23. SelectTrigger,
  24. SelectValue,
  25. } from '@/components/ui/select'
  26. import { Switch } from '@/components/ui/switch'
  27. import { FormDirtyIndicator } from '../components/form-dirty-indicator'
  28. import { FormNavigationGuard } from '../components/form-navigation-guard'
  29. import { SettingsSection } from '../components/settings-section'
  30. import { useSettingsForm } from '../hooks/use-settings-form'
  31. import { useUpdateOption } from '../hooks/use-update-option'
  32. const createPricingSchema = (t: (key: string) => string) =>
  33. z
  34. .object({
  35. QuotaPerUnit: z.coerce.number().min(0, t('Value must be at least 0')),
  36. USDExchangeRate: z.coerce
  37. .number()
  38. .min(0.0001, t('Exchange rate must be greater than 0')),
  39. DisplayInCurrencyEnabled: z.boolean(),
  40. DisplayTokenStatEnabled: z.boolean(),
  41. general_setting: z.object({
  42. quota_display_type: z.enum(['USD', 'CNY', 'TOKENS', 'CUSTOM']),
  43. custom_currency_symbol: z.string().max(8).optional(),
  44. custom_currency_exchange_rate: z.coerce
  45. .number()
  46. .min(0.0001, t('Exchange rate must be greater than 0'))
  47. .optional(),
  48. }),
  49. })
  50. .superRefine((data, ctx) => {
  51. const displayType = data.general_setting.quota_display_type
  52. if (displayType === 'CUSTOM') {
  53. if (!data.general_setting.custom_currency_symbol?.trim()) {
  54. ctx.addIssue({
  55. code: z.ZodIssueCode.custom,
  56. path: ['general_setting', 'custom_currency_symbol'],
  57. message: t('Custom currency symbol is required'),
  58. })
  59. }
  60. if (data.general_setting.custom_currency_exchange_rate == null) {
  61. ctx.addIssue({
  62. code: z.ZodIssueCode.custom,
  63. path: ['general_setting', 'custom_currency_exchange_rate'],
  64. message: t('Exchange rate is required'),
  65. })
  66. }
  67. }
  68. })
  69. type PricingFormValues = z.infer<ReturnType<typeof createPricingSchema>>
  70. type PricingSectionProps = {
  71. defaultValues: PricingFormValues
  72. }
  73. export function PricingSection({ defaultValues }: PricingSectionProps) {
  74. const { t } = useTranslation()
  75. const updateOption = useUpdateOption()
  76. const pricingSchema = createPricingSchema(t)
  77. const { form, handleSubmit, handleReset, isDirty, isSubmitting } =
  78. useSettingsForm<PricingFormValues>({
  79. resolver: zodResolver(pricingSchema) as Resolver<
  80. PricingFormValues,
  81. unknown,
  82. PricingFormValues
  83. >,
  84. defaultValues,
  85. onSubmit: async (_data, changedFields) => {
  86. for (const [key, value] of Object.entries(changedFields)) {
  87. if (value === undefined || value === null) continue
  88. if (typeof value === 'object') continue
  89. let serialized: string | boolean = value as string | boolean
  90. if (typeof value === 'boolean') {
  91. serialized = String(value)
  92. } else if (typeof value === 'number') {
  93. serialized = Number.isFinite(value) ? String(value) : '0'
  94. }
  95. await updateOption.mutateAsync({
  96. key,
  97. value: serialized,
  98. })
  99. }
  100. },
  101. })
  102. const displayType = form.watch('general_setting.quota_display_type') ?? 'USD'
  103. const displayInCurrencyEnabled = form.watch('DisplayInCurrencyEnabled')
  104. const showTokensOnlyOption = displayType === 'TOKENS'
  105. const showQuotaPerUnit =
  106. displayType === 'TOKENS' ||
  107. defaultValues.QuotaPerUnit !== DEFAULT_CURRENCY_CONFIG.quotaPerUnit
  108. const showDisplayInCurrencyOption = displayInCurrencyEnabled === false
  109. return (
  110. <>
  111. <FormNavigationGuard when={isDirty} />
  112. <SettingsSection
  113. title={t('Pricing & Display')}
  114. description={t('Configure pricing model and display options')}
  115. >
  116. <Form {...form}>
  117. <form onSubmit={handleSubmit} className='space-y-6'>
  118. <FormDirtyIndicator isDirty={isDirty} />
  119. {showQuotaPerUnit && (
  120. <FormField
  121. control={form.control}
  122. name='QuotaPerUnit'
  123. render={({ field }) => (
  124. <FormItem>
  125. <FormLabel>{t('Quota Per Unit')}</FormLabel>
  126. <FormControl>
  127. <Input
  128. type='number'
  129. step='0.01'
  130. value={field.value as number}
  131. disabled
  132. name={field.name}
  133. onBlur={field.onBlur}
  134. ref={field.ref}
  135. />
  136. </FormControl>
  137. <FormDescription>
  138. {t('Number of tokens per unit quota')}
  139. </FormDescription>
  140. <FormMessage />
  141. </FormItem>
  142. )}
  143. />
  144. )}
  145. <FormField
  146. control={form.control}
  147. name='general_setting.quota_display_type'
  148. render={({ field }) => (
  149. <FormItem>
  150. <FormLabel>{t('Display Mode')}</FormLabel>
  151. <Select
  152. items={[
  153. { value: 'USD', label: t('USD') },
  154. { value: 'CNY', label: t('CNY') },
  155. { value: 'CUSTOM', label: t('Custom Currency') },
  156. { value: 'TOKENS', label: t('Tokens Only') },
  157. ]}
  158. value={field.value}
  159. onValueChange={field.onChange}
  160. >
  161. <FormControl>
  162. <SelectTrigger>
  163. <SelectValue placeholder={t('Select display mode')} />
  164. </SelectTrigger>
  165. </FormControl>
  166. <SelectContent alignItemWithTrigger={false}>
  167. <SelectGroup>
  168. <SelectItem value='USD'>{t('USD')}</SelectItem>
  169. <SelectItem value='CNY'>{t('CNY')}</SelectItem>
  170. <SelectItem value='CUSTOM'>
  171. {t('Custom Currency')}
  172. </SelectItem>
  173. {showTokensOnlyOption && (
  174. <SelectItem value='TOKENS'>
  175. {t('Tokens Only')}
  176. </SelectItem>
  177. )}
  178. </SelectGroup>
  179. </SelectContent>
  180. </Select>
  181. <FormDescription>
  182. {t('Choose how quota values are shown to users')}
  183. </FormDescription>
  184. <FormMessage />
  185. </FormItem>
  186. )}
  187. />
  188. {displayType !== 'TOKENS' && (
  189. <FormField
  190. control={form.control}
  191. name='USDExchangeRate'
  192. render={({ field }) => (
  193. <FormItem>
  194. <FormLabel>
  195. {displayType === 'CNY'
  196. ? t('CNY per USD')
  197. : displayType === 'USD'
  198. ? t('USD Exchange Rate')
  199. : t('USD Exchange Rate')}
  200. </FormLabel>
  201. <FormControl>
  202. <Input
  203. type='number'
  204. step='0.01'
  205. value={field.value as number}
  206. onChange={(e) => field.onChange(e.target.valueAsNumber)}
  207. name={field.name}
  208. onBlur={field.onBlur}
  209. ref={field.ref}
  210. />
  211. </FormControl>
  212. <FormDescription>
  213. {t(
  214. 'Real exchange rate between USD and your payment gateway currency'
  215. )}
  216. </FormDescription>
  217. <FormMessage />
  218. </FormItem>
  219. )}
  220. />
  221. )}
  222. {displayType === 'CUSTOM' && (
  223. <div className='grid gap-4 sm:grid-cols-2'>
  224. <FormField
  225. control={form.control}
  226. name='general_setting.custom_currency_symbol'
  227. render={({ field }) => (
  228. <FormItem>
  229. <FormLabel>{t('Custom Currency Symbol')}</FormLabel>
  230. <FormControl>
  231. <Input
  232. type='text'
  233. value={field.value ?? ''}
  234. onChange={field.onChange}
  235. name={field.name}
  236. onBlur={field.onBlur}
  237. ref={field.ref}
  238. maxLength={8}
  239. placeholder={t('e.g. ¥ or HK$')}
  240. />
  241. </FormControl>
  242. <FormDescription>
  243. {t('Prefix used when displaying prices')}
  244. </FormDescription>
  245. <FormMessage />
  246. </FormItem>
  247. )}
  248. />
  249. <FormField
  250. control={form.control}
  251. name='general_setting.custom_currency_exchange_rate'
  252. render={({ field }) => (
  253. <FormItem>
  254. <FormLabel>{t('Units per USD')}</FormLabel>
  255. <FormControl>
  256. <Input
  257. type='number'
  258. step='0.01'
  259. value={field.value ?? ''}
  260. onChange={(e) =>
  261. field.onChange(
  262. e.target.value === ''
  263. ? undefined
  264. : e.target.valueAsNumber
  265. )
  266. }
  267. name={field.name}
  268. onBlur={field.onBlur}
  269. ref={field.ref}
  270. placeholder={t('e.g. 8 means 1 USD = 8 units')}
  271. />
  272. </FormControl>
  273. <FormDescription>
  274. {t('Conversion rate from USD to your custom currency')}
  275. </FormDescription>
  276. <FormMessage />
  277. </FormItem>
  278. )}
  279. />
  280. </div>
  281. )}
  282. {showDisplayInCurrencyOption && (
  283. <FormField
  284. control={form.control}
  285. name='DisplayInCurrencyEnabled'
  286. render={({ field }) => (
  287. <FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
  288. <div className='space-y-0.5'>
  289. <FormLabel className='text-base'>
  290. {t('Display in Currency')}
  291. </FormLabel>
  292. <FormDescription>
  293. {displayType === 'TOKENS'
  294. ? t(
  295. 'Tokens-only mode will show raw quota values regardless of this toggle.'
  296. )
  297. : t('Show prices in currency instead of quota.')}
  298. </FormDescription>
  299. </div>
  300. <FormControl>
  301. <Switch
  302. checked={field.value}
  303. onCheckedChange={field.onChange}
  304. />
  305. </FormControl>
  306. </FormItem>
  307. )}
  308. />
  309. )}
  310. <FormField
  311. control={form.control}
  312. name='DisplayTokenStatEnabled'
  313. render={({ field }) => (
  314. <FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
  315. <div className='space-y-0.5'>
  316. <FormLabel className='text-base'>
  317. {t('Display Token Statistics')}
  318. </FormLabel>
  319. <FormDescription>
  320. {t('Show token usage statistics in the UI')}
  321. </FormDescription>
  322. </div>
  323. <FormControl>
  324. <Switch
  325. checked={field.value}
  326. onCheckedChange={field.onChange}
  327. />
  328. </FormControl>
  329. </FormItem>
  330. )}
  331. />
  332. <div className='flex gap-2'>
  333. <Button
  334. type='submit'
  335. disabled={updateOption.isPending || isSubmitting}
  336. >
  337. {updateOption.isPending ? t('Saving...') : t('Save Changes')}
  338. </Button>
  339. <Button
  340. type='button'
  341. variant='outline'
  342. onClick={handleReset}
  343. disabled={!isDirty || updateOption.isPending || isSubmitting}
  344. >
  345. <RotateCcw className='mr-2 h-4 w-4' />
  346. {t('Reset')}
  347. </Button>
  348. </div>
  349. </form>
  350. </Form>
  351. </SettingsSection>
  352. </>
  353. )
  354. }