Commit 2154fce0 by CaIon

feat(payment): enhance payment method configuration with icons and improve UI interactions

parent a2f3ac02
...@@ -20,17 +20,17 @@ var USDExchangeRate = 7.3 ...@@ -20,17 +20,17 @@ var USDExchangeRate = 7.3
var PayMethods = []map[string]string{ var PayMethods = []map[string]string{
{ {
"name": "支付宝", "name": "支付宝",
"color": "rgba(var(--semi-blue-5), 1)", "icon": "SiAlipay",
"type": "alipay", "type": "alipay",
}, },
{ {
"name": "微信", "name": "微信",
"color": "rgba(var(--semi-green-5), 1)", "icon": "SiWechat",
"type": "wxpay", "type": "wxpay",
}, },
{ {
"name": "自定义1", "name": "自定义1",
"color": "black", "icon": "LuCreditCard",
"type": "custom1", "type": "custom1",
"min_topup": "50", "min_topup": "50",
}, },
......
...@@ -39,6 +39,7 @@ const BRAND_AND_LITERAL_KEYS = new Set([ ...@@ -39,6 +39,7 @@ const BRAND_AND_LITERAL_KEYS = new Set([
'AccessKey / SecretAccessKey', 'AccessKey / SecretAccessKey',
'AZURE_OPENAI_ENDPOINT *', 'AZURE_OPENAI_ENDPOINT *',
'Baidu V2', 'Baidu V2',
'CC Switch',
'ChatGPT', 'ChatGPT',
'ChatGPT Subscription (Codex)', 'ChatGPT Subscription (Codex)',
'Claude', 'Claude',
...@@ -317,4 +318,3 @@ main().catch((err) => { ...@@ -317,4 +318,3 @@ main().catch((err) => {
console.error(err) console.error(err)
process.exitCode = 1 process.exitCode = 1
}) })
/*
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 type { IconBaseProps, IconType } from 'react-icons'
type IconPackModule = Record<string, unknown>
type IconPackLoader = () => Promise<IconPackModule>
const ICON_PACK_LOADERS = {
ai: () => import('react-icons/ai').then((module) => module as IconPackModule),
bi: () => import('react-icons/bi').then((module) => module as IconPackModule),
bs: () => import('react-icons/bs').then((module) => module as IconPackModule),
cg: () => import('react-icons/cg').then((module) => module as IconPackModule),
ci: () => import('react-icons/ci').then((module) => module as IconPackModule),
di: () => import('react-icons/di').then((module) => module as IconPackModule),
fa: () => import('react-icons/fa').then((module) => module as IconPackModule),
fa6: () =>
import('react-icons/fa6').then((module) => module as IconPackModule),
fc: () => import('react-icons/fc').then((module) => module as IconPackModule),
fi: () => import('react-icons/fi').then((module) => module as IconPackModule),
gi: () => import('react-icons/gi').then((module) => module as IconPackModule),
go: () => import('react-icons/go').then((module) => module as IconPackModule),
gr: () => import('react-icons/gr').then((module) => module as IconPackModule),
hi: () => import('react-icons/hi').then((module) => module as IconPackModule),
hi2: () =>
import('react-icons/hi2').then((module) => module as IconPackModule),
im: () => import('react-icons/im').then((module) => module as IconPackModule),
io: () => import('react-icons/io').then((module) => module as IconPackModule),
io5: () =>
import('react-icons/io5').then((module) => module as IconPackModule),
lia: () =>
import('react-icons/lia').then((module) => module as IconPackModule),
lu: () => import('react-icons/lu').then((module) => module as IconPackModule),
md: () => import('react-icons/md').then((module) => module as IconPackModule),
pi: () => import('react-icons/pi').then((module) => module as IconPackModule),
ri: () => import('react-icons/ri').then((module) => module as IconPackModule),
rx: () => import('react-icons/rx').then((module) => module as IconPackModule),
si: () => import('react-icons/si').then((module) => module as IconPackModule),
sl: () => import('react-icons/sl').then((module) => module as IconPackModule),
tb: () => import('react-icons/tb').then((module) => module as IconPackModule),
tfi: () =>
import('react-icons/tfi').then((module) => module as IconPackModule),
ti: () => import('react-icons/ti').then((module) => module as IconPackModule),
vsc: () =>
import('react-icons/vsc').then((module) => module as IconPackModule),
wi: () => import('react-icons/wi').then((module) => module as IconPackModule),
} satisfies Record<string, IconPackLoader>
type IconPackId = keyof typeof ICON_PACK_LOADERS
const ICON_PACK_CACHE = new Map<IconPackId, Promise<IconPackModule>>()
const ICON_PACK_CANDIDATES: Array<[RegExp, IconPackId[]]> = [
[/^Ai[A-Z0-9]/, ['ai']],
[/^Bi[A-Z0-9]/, ['bi']],
[/^Bs[A-Z0-9]/, ['bs']],
[/^Cg[A-Z0-9]/, ['cg']],
[/^Ci[A-Z0-9]/, ['ci']],
[/^Di[A-Z0-9]/, ['di']],
[/^Fa[A-Z0-9]/, ['fa6', 'fa']],
[/^Fc[A-Z0-9]/, ['fc']],
[/^Fi[A-Z0-9]/, ['fi']],
[/^Gi[A-Z0-9]/, ['gi']],
[/^Go[A-Z0-9]/, ['go']],
[/^Gr[A-Z0-9]/, ['gr']],
[/^Hi[A-Z0-9]/, ['hi2', 'hi']],
[/^Im[A-Z0-9]/, ['im']],
[/^Io[A-Z0-9]/, ['io5', 'io']],
[/^Lia[A-Z0-9]/, ['lia']],
[/^Lu[A-Z0-9]/, ['lu']],
[/^Md[A-Z0-9]/, ['md']],
[/^Pi[A-Z0-9]/, ['pi']],
[/^Ri[A-Z0-9]/, ['ri']],
[/^Rx[A-Z0-9]/, ['rx']],
[/^Si[A-Z0-9]/, ['si']],
[/^Sl[A-Z0-9]/, ['sl']],
[/^Tb[A-Z0-9]/, ['tb']],
[/^Tfi[A-Z0-9]/, ['tfi']],
[/^Ti[A-Z0-9]/, ['ti']],
[/^Vsc[A-Z0-9]/, ['vsc']],
[/^Wi[A-Z0-9]/, ['wi']],
]
function normalizeIconName(name: string | null | undefined): string | null {
const trimmed = name?.trim()
if (!trimmed || !/^[A-Z][A-Za-z0-9]*$/.test(trimmed)) return null
return trimmed
}
function getCandidatePacks(iconName: string): IconPackId[] {
return (
ICON_PACK_CANDIDATES.find(([pattern]) => pattern.test(iconName))?.[1] ?? []
)
}
function loadIconPack(packId: IconPackId): Promise<IconPackModule> {
const cached = ICON_PACK_CACHE.get(packId)
if (cached) return cached
const promise = ICON_PACK_LOADERS[packId]()
ICON_PACK_CACHE.set(packId, promise)
return promise
}
function isIconComponent(value: unknown): value is IconType {
return typeof value === 'function'
}
async function resolveReactIcon(iconName: string): Promise<IconType | null> {
for (const packId of getCandidatePacks(iconName)) {
try {
const icon = (await loadIconPack(packId))[iconName]
if (isIconComponent(icon)) return icon
} catch {
// Missing chunks or unknown packs should behave the same as unknown names.
}
}
return null
}
type ReactIconByNameProps = IconBaseProps & {
name?: string | null
}
type ResolvedIconState = {
iconName: string
Icon: IconType | null
}
export function ReactIconByName({ name, ...props }: ReactIconByNameProps) {
const iconName = normalizeIconName(name)
const [resolvedIcon, setResolvedIcon] = useState<ResolvedIconState | null>(
null
)
useEffect(() => {
let cancelled = false
if (!iconName) return
void resolveReactIcon(iconName).then((Icon) => {
if (!cancelled) setResolvedIcon({ iconName, Icon })
})
return () => {
cancelled = true
}
}, [iconName])
if (!iconName || resolvedIcon?.iconName !== iconName || !resolvedIcon.Icon) {
return null
}
const Icon = resolvedIcon.Icon
return <Icon {...props} />
}
...@@ -30,8 +30,8 @@ import { ...@@ -30,8 +30,8 @@ import {
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
type RequiredTextPart = { type RequiredTextPart = {
type: 'input' | 'static' type: 'input' | 'static'
...@@ -63,6 +63,10 @@ type RiskAcknowledgementDialogProps = { ...@@ -63,6 +63,10 @@ type RiskAcknowledgementDialogProps = {
className?: string className?: string
} }
function getRequiredTextRows(text: string) {
return Math.max(1, Math.ceil(Array.from(text).length / 42))
}
export function RiskAcknowledgementDialog({ export function RiskAcknowledgementDialog({
open, open,
onOpenChange, onOpenChange,
...@@ -90,15 +94,21 @@ export function RiskAcknowledgementDialog({ ...@@ -90,15 +94,21 @@ export function RiskAcknowledgementDialog({
const normalizedRequiredTextParts = useMemo< const normalizedRequiredTextParts = useMemo<
NormalizedRequiredTextPart[] NormalizedRequiredTextPart[]
>(() => { >(() => {
let inputIndex = 0 return requiredTextParts.reduce<{
return requiredTextParts.map((part) => { parts: NormalizedRequiredTextPart[]
if (part.type === 'input') { inputIndex: number
const normalizedPart = { ...part, inputIndex } }>(
inputIndex += 1 (acc, part) => {
return normalizedPart if (part.type !== 'input') {
return { ...acc, parts: [...acc.parts, part] }
} }
return part return {
}) parts: [...acc.parts, { ...part, inputIndex: acc.inputIndex }],
inputIndex: acc.inputIndex + 1,
}
},
{ parts: [], inputIndex: 0 }
).parts
}, [requiredTextParts]) }, [requiredTextParts])
const requiredTextInputCount = useMemo( const requiredTextInputCount = useMemo(
...@@ -114,9 +124,12 @@ export function RiskAcknowledgementDialog({ ...@@ -114,9 +124,12 @@ export function RiskAcknowledgementDialog({
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const timer = window.setTimeout(() => {
setCheckedItems(Array(checklist.length).fill(false)) setCheckedItems(Array(checklist.length).fill(false))
setTypedText('') setTypedText('')
setTypedTextParts(Array(requiredTextInputCount).fill('')) setTypedTextParts(Array(requiredTextInputCount).fill(''))
}, 0)
return () => window.clearTimeout(timer)
}, [open, checklist.length, requiredTextInputCount]) }, [open, checklist.length, requiredTextInputCount])
const allChecked = useMemo(() => { const allChecked = useMemo(() => {
...@@ -225,21 +238,21 @@ export function RiskAcknowledgementDialog({ ...@@ -225,21 +238,21 @@ export function RiskAcknowledgementDialog({
<Label className='text-sm font-medium'> <Label className='text-sm font-medium'>
{inputPrompt ?? t('Please type the following text to confirm:')} {inputPrompt ?? t('Please type the following text to confirm:')}
</Label> </Label>
<div className='bg-background border-border rounded-md border px-3 py-2 font-mono text-sm break-all'> <div className='bg-background border-border rounded-md border px-3 py-2 font-mono text-sm leading-6 break-words whitespace-pre-wrap'>
{requiredTextToDisplay} {requiredTextToDisplay}
</div> </div>
{hasSegmentedRequiredText ? ( {hasSegmentedRequiredText ? (
<div className='flex flex-wrap items-center gap-2'> <div className='flex flex-col gap-2'>
{normalizedRequiredTextParts.map((part, index) => {normalizedRequiredTextParts.map((part, index) =>
part.type === 'static' ? ( part.type === 'static' ? (
<span <span
key={`static-${index}`} key={`static-${index}`}
className='text-muted-foreground bg-background/70 border-border rounded-md border px-2 py-1.5 font-mono text-sm select-none' className='text-muted-foreground bg-background/70 border-border w-fit rounded-md border px-2 py-1.5 font-mono text-sm select-none'
> >
{part.text} {part.text}
</span> </span>
) : ( ) : (
<Input <Textarea
key={`input-${index}`} key={`input-${index}`}
value={typedTextParts[part.inputIndex ?? 0] ?? ''} value={typedTextParts[part.inputIndex ?? 0] ?? ''}
onChange={(event) => onChange={(event) =>
...@@ -254,30 +267,27 @@ export function RiskAcknowledgementDialog({ ...@@ -254,30 +267,27 @@ export function RiskAcknowledgementDialog({
inputPlaceholder ?? inputPlaceholder ??
t('Type the confirmation text here') t('Type the confirmation text here')
} }
rows={getRequiredTextRows(part.text)}
autoFocus={open && part.inputIndex === 0} autoFocus={open && part.inputIndex === 0}
onCopy={(event) => event.preventDefault()} wrap='soft'
onCut={(event) => event.preventDefault()}
onPaste={(event) => event.preventDefault()}
onDrop={(event) => event.preventDefault()}
aria-invalid={hasTypedRequiredText && !typedMatched} aria-invalid={hasTypedRequiredText && !typedMatched}
className='w-full font-mono sm:w-64' className='min-h-10 resize-none overflow-hidden font-mono text-sm leading-6'
/> />
) )
)} )}
</div> </div>
) : ( ) : (
<Input <Textarea
value={typedText} value={typedText}
onChange={(event) => setTypedText(event.target.value)} onChange={(event) => setTypedText(event.target.value)}
placeholder={ placeholder={
inputPlaceholder ?? t('Type the confirmation text here') inputPlaceholder ?? t('Type the confirmation text here')
} }
rows={getRequiredTextRows(requiredText)}
autoFocus={open} autoFocus={open}
onCopy={(event) => event.preventDefault()} wrap='soft'
onCut={(event) => event.preventDefault()}
onPaste={(event) => event.preventDefault()}
onDrop={(event) => event.preventDefault()}
aria-invalid={hasTypedRequiredText && !typedMatched} aria-invalid={hasTypedRequiredText && !typedMatched}
className='min-h-16 resize-none overflow-hidden font-mono text-sm leading-6'
/> />
)} )}
{hasTypedRequiredText && !typedMatched ? ( {hasTypedRequiredText && !typedMatched ? (
......
...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useEffect, useMemo } from 'react' import { useEffect } from 'react'
import * as z from 'zod' import * as z from 'zod'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
...@@ -34,12 +34,13 @@ import { ...@@ -34,12 +34,13 @@ import {
} from '@/components/ui/form' } from '@/components/ui/form'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog' import { Dialog } from '@/components/dialog'
import { ReactIconByName } from '@/components/react-icon-by-name'
const createPaymentMethodDialogSchema = (t: (key: string) => string) => const createPaymentMethodDialogSchema = (t: (key: string) => string) =>
z.object({ z.object({
name: z.string().min(1, t('Payment method name is required')), name: z.string().min(1, t('Payment method name is required')),
type: z.string().min(1, t('Payment method type is required')), type: z.string().min(1, t('Payment type key is required')),
color: z.string().min(1, t('Color is required')), icon: z.string().optional(),
min_topup: z.string().optional(), min_topup: z.string().optional(),
}) })
...@@ -52,8 +53,9 @@ const PAYMENT_METHOD_FORM_ID = 'payment-method-form' ...@@ -52,8 +53,9 @@ const PAYMENT_METHOD_FORM_ID = 'payment-method-form'
export type PaymentMethodData = { export type PaymentMethodData = {
name: string name: string
type: string type: string
color: string icon?: string
min_topup?: string min_topup?: string
color?: string
} }
type PaymentMethodDialogProps = { type PaymentMethodDialogProps = {
...@@ -63,42 +65,14 @@ type PaymentMethodDialogProps = { ...@@ -63,42 +65,14 @@ type PaymentMethodDialogProps = {
editData?: PaymentMethodData | null editData?: PaymentMethodData | null
} }
const PAYMENT_TYPES = [ const PAYMENT_TYPE_ICON_NAMES: Record<string, string> = {
{ value: 'alipay', label: 'Alipay' }, alipay: 'SiAlipay',
{ value: 'wxpay', label: 'WeChat Pay' }, stripe: 'SiStripe',
{ value: 'stripe', label: 'Stripe' }, waffo_pancake: 'LuCreditCard',
] wxpay: 'SiWechat',
const getColorPreview = (color: string) => {
if (color.includes('var(--')) {
return null
}
return color
} }
const COLOR_PRESETS = [ const getDefaultIconName = (type: string) => PAYMENT_TYPE_ICON_NAMES[type] ?? ''
{ value: '#1677FF', label: 'Blue (Alipay)' },
{ value: '#07C160', label: 'Green (WeChat)' },
{ value: '#635BFF', label: 'Purple (Stripe)' },
{ value: '#1890FF', label: 'Sky Blue' },
{ value: '#52C41A', label: 'Lime Green' },
{ value: 'black', label: 'Black' },
{ value: '#FF4D4F', label: 'Red' },
{ value: '#FFA940', label: 'Orange' },
].map((preset) => {
const previewColor = getColorPreview(preset.value)
return {
...preset,
icon: previewColor ? (
<div
className='size-4 rounded border'
style={{ backgroundColor: previewColor }}
/>
) : (
<div className='bg-muted size-4 rounded border' />
),
}
})
export function PaymentMethodDialog({ export function PaymentMethodDialog({
open, open,
...@@ -109,41 +83,60 @@ export function PaymentMethodDialog({ ...@@ -109,41 +83,60 @@ export function PaymentMethodDialog({
const { t } = useTranslation() const { t } = useTranslation()
const isEditMode = !!editData const isEditMode = !!editData
const paymentMethodDialogSchema = createPaymentMethodDialogSchema(t) const paymentMethodDialogSchema = createPaymentMethodDialogSchema(t)
const paymentTypeOptions = [
{
iconName: 'SiAlipay',
label: `${t('Alipay')} (Epay: alipay)`,
name: t('Alipay'),
value: 'alipay',
},
{
iconName: 'SiWechat',
label: `${t('WeChat Pay')} (Epay: wxpay)`,
name: t('WeChat Pay'),
value: 'wxpay',
},
{
iconName: 'SiStripe',
label: `${t('Stripe')} (stripe)`,
name: t('Stripe'),
value: 'stripe',
},
{
iconName: 'LuCreditCard',
label: 'Waffo Pancake (waffo_pancake)',
name: 'Waffo Pancake',
value: 'waffo_pancake',
},
]
const getPaymentTypeOption = (value: string) =>
paymentTypeOptions.find((option) => option.value === value)
const form = useForm<PaymentMethodDialogFormValues>({ const form = useForm<PaymentMethodDialogFormValues>({
resolver: zodResolver(paymentMethodDialogSchema), resolver: zodResolver(paymentMethodDialogSchema),
defaultValues: { defaultValues: {
name: '', name: '',
type: '', type: '',
color: '', icon: '',
min_topup: '', min_topup: '',
}, },
}) })
const colorValue = form.watch('color') const iconValue = form.watch('icon')
const colorPreview = useMemo(() => {
if (!colorValue) return null
try {
// For CSS variables like rgba(var(--semi-blue-5), 1), we can't preview accurately
// but we can detect common patterns
if (colorValue.includes('var(--')) {
return null // Can't preview CSS variables reliably
}
return colorValue
} catch {
return null
}
}, [colorValue])
useEffect(() => { useEffect(() => {
if (editData) { if (editData) {
form.reset(editData) form.reset({
name: editData.name,
type: editData.type,
icon: editData.icon ?? getDefaultIconName(editData.type),
min_topup: editData.min_topup ?? '',
})
} else { } else {
form.reset({ form.reset({
name: '', name: '',
type: '', type: '',
color: '', icon: '',
min_topup: '', min_topup: '',
}) })
} }
...@@ -153,7 +146,9 @@ export function PaymentMethodDialog({ ...@@ -153,7 +146,9 @@ export function PaymentMethodDialog({
const data: PaymentMethodData = { const data: PaymentMethodData = {
name: values.name, name: values.name,
type: values.type, type: values.type,
color: values.color, }
if (values.icon && values.icon.trim() !== '') {
data.icon = values.icon.trim()
} }
if (values.min_topup && values.min_topup.trim() !== '') { if (values.min_topup && values.min_topup.trim() !== '') {
data.min_topup = values.min_topup data.min_topup = values.min_topup
...@@ -215,19 +210,46 @@ export function PaymentMethodDialog({ ...@@ -215,19 +210,46 @@ export function PaymentMethodDialog({
name='type' name='type'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('Type')}</FormLabel> <FormLabel>{t('Payment type key')}</FormLabel>
<FormControl> <FormControl>
<Combobox <Combobox
options={PAYMENT_TYPES} options={paymentTypeOptions}
value={field.value} value={field.value}
onValueChange={field.onChange} onValueChange={(value) => {
placeholder={t('Select or enter payment type')} if (value === null) return
searchPlaceholder={t('Search payment types...')} const currentIcon = form.getValues('icon')?.trim()
const currentName = form.getValues('name')?.trim()
const previousOption = getPaymentTypeOption(field.value)
const nextOption = getPaymentTypeOption(value)
field.onChange(value)
if (
nextOption?.iconName &&
(!currentIcon ||
currentIcon === previousOption?.iconName)
) {
form.setValue('icon', nextOption.iconName, {
shouldDirty: true,
})
}
if (
nextOption?.name &&
(!currentName || currentName === previousOption?.name)
) {
form.setValue('name', nextOption.name, {
shouldDirty: true,
})
}
}}
placeholder={t('Select or enter payment type key')}
searchPlaceholder={t('Search payment type keys...')}
allowCustomValue allowCustomValue
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription className='leading-relaxed'>
{t('Select from presets or type custom identifier.')} {t(
'Used to decide the payment flow. Built-in keys include stripe for Stripe and waffo_pancake for Waffo Pancake; other values are sent to Epay as the type parameter.'
)}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
...@@ -236,32 +258,30 @@ export function PaymentMethodDialog({ ...@@ -236,32 +258,30 @@ export function PaymentMethodDialog({
<FormField <FormField
control={form.control} control={form.control}
name='color' name='icon'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('Color')}</FormLabel> <FormLabel>{t('Icon')}</FormLabel>
<FormControl> <FormControl>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<Combobox <Input
options={COLOR_PRESETS} placeholder={t('e.g., SiAlipay')}
value={field.value} {...field}
onValueChange={field.onChange}
placeholder={t('Select or enter color value')}
searchPlaceholder={t('Search colors...')}
allowCustomValue
className='flex-1' className='flex-1'
/> />
{colorPreview && ( {iconValue && (
<div <ReactIconByName
className='size-9 shrink-0 rounded border' name={iconValue}
style={{ backgroundColor: colorPreview }} className='text-muted-foreground size-5 shrink-0'
title={colorPreview} title={iconValue}
/> />
)} )}
</div> </div>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{t('Select preset or enter custom CSS color value.')} {t(
'Enter a react-icons component name. Invalid names show no icon.'
)}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
......
...@@ -27,6 +27,7 @@ import { ...@@ -27,6 +27,7 @@ import {
PopoverTrigger, PopoverTrigger,
} from '@/components/ui/popover' } from '@/components/ui/popover'
import { StaticDataTable } from '@/components/data-table' import { StaticDataTable } from '@/components/data-table'
import { ReactIconByName } from '@/components/react-icon-by-name'
import { safeJsonParseWithValidation } from '../utils/json-parser' import { safeJsonParseWithValidation } from '../utils/json-parser'
import { isArray } from '../utils/json-validators' import { isArray } from '../utils/json-validators'
import { import {
...@@ -39,47 +40,70 @@ type PaymentMethodsVisualEditorProps = { ...@@ -39,47 +40,70 @@ type PaymentMethodsVisualEditorProps = {
onChange: (value: string) => void onChange: (value: string) => void
} }
const PAYMENT_TEMPLATES = [ const PAYMENT_TYPE_ICON_NAMES: Record<string, string> = {
alipay: 'SiAlipay',
stripe: 'SiStripe',
waffo_pancake: 'LuCreditCard',
wxpay: 'SiWechat',
}
function getDefaultIconName(type: string) {
return PAYMENT_TYPE_ICON_NAMES[type] ?? ''
}
function getEffectiveIconName(method: PaymentMethodData) {
return method.icon || getDefaultIconName(method.type)
}
export function PaymentMethodsVisualEditor({
value,
onChange,
}: PaymentMethodsVisualEditorProps) {
const { t } = useTranslation()
const paymentTemplates = [
{ {
name: 'Alipay', name: t('Epay Alipay'),
template: { template: {
color: 'rgba(var(--semi-blue-5), 1)', icon: getDefaultIconName('alipay'),
name: '支付宝', name: '支付宝',
type: 'alipay', type: 'alipay',
}, },
}, },
{ {
name: 'WeChat Pay', name: t('Epay WeChat Pay'),
template: { template: {
color: 'rgba(var(--semi-green-5), 1)', icon: getDefaultIconName('wxpay'),
name: '微信', name: '微信',
type: 'wxpay', type: 'wxpay',
}, },
}, },
{ {
name: 'Stripe', name: t('Stripe'),
template: { template: {
color: 'rgba(var(--semi-green-5), 1)', icon: getDefaultIconName('stripe'),
min_topup: '10',
name: 'Stripe', name: 'Stripe',
type: 'stripe', type: 'stripe',
}, },
}, },
{ {
name: 'Custom', name: 'Waffo Pancake',
template: {
icon: getDefaultIconName('waffo_pancake'),
name: 'Waffo Pancake',
type: 'waffo_pancake',
},
},
{
name: t('Custom Epay method'),
template: { template: {
color: 'black', icon: 'LuCreditCard',
min_topup: '50', min_topup: '50',
name: '自定义1', name: '自定义1',
type: 'custom1', type: 'custom1',
}, },
}, },
] ]
export function PaymentMethodsVisualEditor({
value,
onChange,
}: PaymentMethodsVisualEditorProps) {
const { t } = useTranslation()
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
const [editData, setEditData] = useState<PaymentMethodData | null>(null) const [editData, setEditData] = useState<PaymentMethodData | null>(null)
...@@ -98,10 +122,11 @@ export function PaymentMethodsVisualEditor({ ...@@ -98,10 +122,11 @@ export function PaymentMethodsVisualEditor({
item !== null && item !== null &&
'name' in item && 'name' in item &&
'type' in item && 'type' in item &&
'color' in item &&
typeof item.name === 'string' && typeof item.name === 'string' &&
typeof item.type === 'string' && typeof item.type === 'string' &&
typeof item.color === 'string' (!('icon' in item) || typeof item.icon === 'string') &&
(!('min_topup' in item) || typeof item.min_topup === 'string') &&
(!('color' in item) || typeof item.color === 'string')
) )
}, [value]) }, [value])
...@@ -111,7 +136,8 @@ export function PaymentMethodsVisualEditor({ ...@@ -111,7 +136,8 @@ export function PaymentMethodsVisualEditor({
return paymentMethods.filter( return paymentMethods.filter(
(method) => (method) =>
method.name.toLowerCase().includes(lowerSearch) || method.name.toLowerCase().includes(lowerSearch) ||
method.type.toLowerCase().includes(lowerSearch) method.type.toLowerCase().includes(lowerSearch) ||
getEffectiveIconName(method).toLowerCase().includes(lowerSearch)
) )
}, [paymentMethods, searchText]) }, [paymentMethods, searchText])
...@@ -202,14 +228,6 @@ export function PaymentMethodsVisualEditor({ ...@@ -202,14 +228,6 @@ export function PaymentMethodsVisualEditor({
} }
} }
const getColorPreview = (color: string) => {
// For CSS variables, show a placeholder
if (color.includes('var(--')) {
return null
}
return color
}
return ( return (
<div className='space-y-4'> <div className='space-y-4'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-center'> <div className='flex flex-col gap-3 sm:flex-row sm:items-center'>
...@@ -235,10 +253,10 @@ export function PaymentMethodsVisualEditor({ ...@@ -235,10 +253,10 @@ export function PaymentMethodsVisualEditor({
<PopoverContent className='w-60'> <PopoverContent className='w-60'>
<div className='space-y-2'> <div className='space-y-2'>
<p className='text-muted-foreground text-xs'> <p className='text-muted-foreground text-xs'>
{t('Quick insert common payment methods')} {t('Quick insert payment entries')}
</p> </p>
<div className='space-y-1'> <div className='space-y-1'>
{PAYMENT_TEMPLATES.map((item) => ( {paymentTemplates.map((item) => (
<Button <Button
key={item.name} key={item.name}
type='button' type='button'
...@@ -297,7 +315,7 @@ export function PaymentMethodsVisualEditor({ ...@@ -297,7 +315,7 @@ export function PaymentMethodsVisualEditor({
}, },
{ {
id: 'type', id: 'type',
header: t('Type'), header: t('Payment type key'),
cell: (method) => ( cell: (method) => (
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'> <code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
{method.type} {method.type}
...@@ -305,23 +323,24 @@ export function PaymentMethodsVisualEditor({ ...@@ -305,23 +323,24 @@ export function PaymentMethodsVisualEditor({
), ),
}, },
{ {
id: 'color', id: 'icon',
header: t('Color'), header: t('Icon'),
cell: (method) => { cell: (method) => {
const colorPreview = getColorPreview(method.color) const iconName = getEffectiveIconName(method)
return ( return iconName ? (
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
{colorPreview && ( <ReactIconByName
<div name={iconName}
className='size-5 shrink-0 rounded border' className='text-muted-foreground size-5 shrink-0'
style={{ backgroundColor: colorPreview }} title={iconName}
/> />
)}
<span className='text-muted-foreground truncate font-mono text-sm'> <span className='text-muted-foreground truncate font-mono text-sm'>
{method.color} {iconName}
</span> </span>
</div> </div>
) : (
<span className='text-muted-foreground text-sm'></span>
) )
}, },
}, },
...@@ -377,7 +396,8 @@ export function PaymentMethodsVisualEditor({ ...@@ -377,7 +396,8 @@ export function PaymentMethodsVisualEditor({
{/* Mobile card view */} {/* Mobile card view */}
<div className='divide-y md:hidden'> <div className='divide-y md:hidden'>
{filteredMethods.map((method, index) => { {filteredMethods.map((method, index) => {
const colorPreview = getColorPreview(method.color) const iconName = getEffectiveIconName(method)
return ( return (
<div key={`${method.type}-${index}`} className='p-4'> <div key={`${method.type}-${index}`} className='p-4'>
<div className='mb-3 flex items-start justify-between'> <div className='mb-3 flex items-start justify-between'>
...@@ -417,19 +437,22 @@ export function PaymentMethodsVisualEditor({ ...@@ -417,19 +437,22 @@ export function PaymentMethodsVisualEditor({
<div className='space-y-2 text-sm'> <div className='space-y-2 text-sm'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<span className='text-muted-foreground min-w-20'> <span className='text-muted-foreground min-w-20'>
{t('Color:')} {t('Icon')}
</span> </span>
<div className='flex items-center gap-2'> {iconName ? (
{colorPreview && ( <div className='flex min-w-0 items-center gap-2'>
<div <ReactIconByName
className='size-5 shrink-0 rounded border' name={iconName}
style={{ backgroundColor: colorPreview }} className='text-muted-foreground size-5 shrink-0'
title={iconName}
/> />
)}
<span className='text-muted-foreground truncate font-mono text-xs'> <span className='text-muted-foreground truncate font-mono text-xs'>
{method.color} {iconName}
</span> </span>
</div> </div>
) : (
<span className='text-muted-foreground text-xs'></span>
)}
</div> </div>
{method.min_topup && ( {method.min_topup && (
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
......
...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useCallback, useMemo } from 'react'
import { useQueryClient, useIsFetching } from '@tanstack/react-query' import { useQueryClient, useIsFetching } from '@tanstack/react-query'
import { useNavigate, getRouteApi } from '@tanstack/react-router' import { useNavigate, getRouteApi } from '@tanstack/react-router'
import { type Table } from '@tanstack/react-table' import { type Table } from '@tanstack/react-table'
...@@ -51,12 +51,57 @@ import { ...@@ -51,12 +51,57 @@ import {
import { useUsageLogsContext } from './usage-logs-provider' import { useUsageLogsContext } from './usage-logs-provider'
const route = getRouteApi('/_authenticated/usage-logs/$section') const route = getRouteApi('/_authenticated/usage-logs/$section')
const logTypeValues = ['0', '1', '2', '3', '4', '5', '6'] as const
type LogTypeValue = (typeof logTypeValues)[number] type LogTypeValue = (typeof LOG_TYPE_FILTERS)[number]['value']
const logTypeValueSet = new Set<string>(
LOG_TYPE_FILTERS.map((type) => type.value)
)
type CommonLogDraft = {
sourceKey: string
filters: CommonLogFilters
logType: LogTypeValue
}
function isLogTypeValue(value: string): value is LogTypeValue { function isLogTypeValue(value: string): value is LogTypeValue {
return (logTypeValues as readonly string[]).includes(value) return logTypeValueSet.has(value)
}
function getLogTypeValue(value: unknown): LogTypeValue {
return Array.isArray(value) &&
value.length === 1 &&
typeof value[0] === 'string' &&
isLogTypeValue(value[0])
? value[0]
: LOG_TYPE_ALL_VALUE
}
function buildSearchSourceKey(values: {
startTime?: unknown
endTime?: unknown
channel?: unknown
model?: unknown
token?: unknown
group?: unknown
username?: unknown
requestId?: unknown
upstreamRequestId?: unknown
type?: unknown
}) {
return [
values.startTime,
values.endTime,
values.channel,
values.model,
values.token,
values.group,
values.username,
values.requestId,
values.upstreamRequestId,
Array.isArray(values.type) ? values.type.join(',') : values.type,
]
.map((value) => String(value ?? ''))
.join('\u001f')
} }
interface CommonLogsFilterBarProps<TData> { interface CommonLogsFilterBarProps<TData> {
...@@ -74,15 +119,21 @@ export function CommonLogsFilterBar<TData>( ...@@ -74,15 +119,21 @@ export function CommonLogsFilterBar<TData>(
const { sensitiveVisible, setSensitiveVisible } = useUsageLogsContext() const { sensitiveVisible, setSensitiveVisible } = useUsageLogsContext()
const fetchingLogs = useIsFetching({ queryKey: ['logs'] }) const fetchingLogs = useIsFetching({ queryKey: ['logs'] })
const [filters, setFilters] = useState<CommonLogFilters>(() => { const searchState = useMemo<CommonLogDraft>(() => {
const { start, end } = getDefaultTimeRange()
return { startTime: start, endTime: end }
})
const [logType, setLogType] = useState<LogTypeValue>(LOG_TYPE_ALL_VALUE)
useEffect(() => {
const { start, end } = getDefaultTimeRange() const { start, end } = getDefaultTimeRange()
setFilters({ const sourceValues = {
startTime: searchParams.startTime,
endTime: searchParams.endTime,
channel: searchParams.channel,
model: searchParams.model,
token: searchParams.token,
group: searchParams.group,
username: searchParams.username,
requestId: searchParams.requestId,
upstreamRequestId: searchParams.upstreamRequestId,
type: searchParams.type,
}
const filters: CommonLogFilters = {
startTime: searchParams.startTime startTime: searchParams.startTime
? new Date(searchParams.startTime) ? new Date(searchParams.startTime)
: start, : start,
...@@ -94,16 +145,12 @@ export function CommonLogsFilterBar<TData>( ...@@ -94,16 +145,12 @@ export function CommonLogsFilterBar<TData>(
username: searchParams.username || undefined, username: searchParams.username || undefined,
requestId: searchParams.requestId || undefined, requestId: searchParams.requestId || undefined,
upstreamRequestId: searchParams.upstreamRequestId || undefined, upstreamRequestId: searchParams.upstreamRequestId || undefined,
}) }
return {
const typeArr = searchParams.type sourceKey: buildSearchSourceKey(sourceValues),
const nextLogType = filters,
Array.isArray(typeArr) && logType: getLogTypeValue(searchParams.type),
typeArr.length === 1 && }
isLogTypeValue(typeArr[0])
? typeArr[0]
: LOG_TYPE_ALL_VALUE
setLogType(nextLogType)
}, [ }, [
searchParams.startTime, searchParams.startTime,
searchParams.endTime, searchParams.endTime,
...@@ -116,12 +163,25 @@ export function CommonLogsFilterBar<TData>( ...@@ -116,12 +163,25 @@ export function CommonLogsFilterBar<TData>(
searchParams.upstreamRequestId, searchParams.upstreamRequestId,
searchParams.type, searchParams.type,
]) ])
const [draft, setDraft] = useState<CommonLogDraft>(() => searchState)
const activeDraft =
draft.sourceKey === searchState.sourceKey ? draft : searchState
const filters = activeDraft.filters
const logType = activeDraft.logType
const handleChange = useCallback( const handleChange = useCallback(
(field: keyof CommonLogFilters, value: Date | string | undefined) => { (field: keyof CommonLogFilters, value: Date | string | undefined) => {
setFilters((prev) => ({ ...prev, [field]: value })) setDraft((current) => {
const base =
current.sourceKey === searchState.sourceKey ? current : searchState
return {
sourceKey: searchState.sourceKey,
filters: { ...base.filters, [field]: value },
logType: base.logType,
}
})
}, },
[] [searchState]
) )
const handleApply = useCallback(() => { const handleApply = useCallback(() => {
...@@ -142,17 +202,23 @@ export function CommonLogsFilterBar<TData>( ...@@ -142,17 +202,23 @@ export function CommonLogsFilterBar<TData>(
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
const { start, end } = getDefaultTimeRange() const { start, end } = getDefaultTimeRange()
const resetFilters: CommonLogFilters = { startTime: start, endTime: end } const resetFilters: CommonLogFilters = { startTime: start, endTime: end }
setFilters(resetFilters) const resetSearch = {
setLogType(LOG_TYPE_ALL_VALUE) type: [LOG_TYPE_ALL_VALUE],
startTime: start.getTime(),
endTime: end.getTime(),
}
setDraft({
sourceKey: buildSearchSourceKey(resetSearch),
filters: resetFilters,
logType: LOG_TYPE_ALL_VALUE,
})
navigate({ navigate({
to: '/usage-logs/$section', to: '/usage-logs/$section',
params: { section: 'common' }, params: { section: 'common' },
search: { search: {
page: 1, page: 1,
type: [LOG_TYPE_ALL_VALUE], ...resetSearch,
startTime: start.getTime(),
endTime: end.getTime(),
}, },
}) })
queryClient.invalidateQueries({ queryKey: ['logs'] }) queryClient.invalidateQueries({ queryKey: ['logs'] })
...@@ -259,9 +325,19 @@ export function CommonLogsFilterBar<TData>( ...@@ -259,9 +325,19 @@ export function CommonLogsFilterBar<TData>(
items={logTypeItems} items={logTypeItems}
value={logType} value={logType}
onValueChange={(value) => { onValueChange={(value) => {
setLogType( const nextLogType =
value !== null && isLogTypeValue(value) ? value : LOG_TYPE_ALL_VALUE value !== null && isLogTypeValue(value) ? value : LOG_TYPE_ALL_VALUE
) setDraft((current) => {
const base =
current.sourceKey === searchState.sourceKey
? current
: searchState
return {
sourceKey: searchState.sourceKey,
filters: base.filters,
logType: nextLogType,
}
})
}} }}
> >
<SelectTrigger> <SelectTrigger>
......
...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useState, useEffect } from 'react' import { useState, useEffect, useCallback } from 'react'
import { getTopupInfo } from '../api' import { getTopupInfo } from '../api'
import { import {
generatePresetAmounts, generatePresetAmounts,
...@@ -70,6 +70,7 @@ function parsePaymentMethods( ...@@ -70,6 +70,7 @@ function parsePaymentMethods(
name: typeof item.name === 'string' ? item.name : '', name: typeof item.name === 'string' ? item.name : '',
type, type,
color: typeof item.color === 'string' ? item.color : undefined, color: typeof item.color === 'string' ? item.color : undefined,
icon: typeof item.icon === 'string' ? item.icon : undefined,
min_topup: min_topup:
type === 'stripe' && normalizedMinTopup <= 0 type === 'stripe' && normalizedMinTopup <= 0
? stripeMinTopup ? stripeMinTopup
...@@ -166,7 +167,7 @@ export function useTopupInfo() { ...@@ -166,7 +167,7 @@ export function useTopupInfo() {
const [presetAmounts, setPresetAmounts] = useState<PresetAmount[]>([]) const [presetAmounts, setPresetAmounts] = useState<PresetAmount[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const fetchTopupInfo = async () => { const fetchTopupInfo = useCallback(async () => {
try { try {
setLoading(true) setLoading(true)
...@@ -211,11 +212,19 @@ export function useTopupInfo() { ...@@ -211,11 +212,19 @@ export function useTopupInfo() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }, [])
useEffect(() => { useEffect(() => {
fetchTopupInfo() let cancelled = false
}, [])
queueMicrotask(() => {
if (!cancelled) void fetchTopupInfo()
})
return () => {
cancelled = true
}
}, [fetchTopupInfo])
return { return {
topupInfo, topupInfo,
......
...@@ -20,6 +20,7 @@ import { type ReactNode } from 'react' ...@@ -20,6 +20,7 @@ import { type ReactNode } from 'react'
import i18next from 'i18next' import i18next from 'i18next'
import { CreditCard, Landmark } from 'lucide-react' import { CreditCard, Landmark } from 'lucide-react'
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si' import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si'
import { ReactIconByName } from '@/components/react-icon-by-name'
import { PAYMENT_TYPES, PAYMENT_ICON_COLORS } from '../constants' import { PAYMENT_TYPES, PAYMENT_ICON_COLORS } from '../constants'
// ============================================================================ // ============================================================================
...@@ -57,16 +58,18 @@ function normalizeHttpIconUrl(raw: string | undefined | null): string | null { ...@@ -57,16 +58,18 @@ function normalizeHttpIconUrl(raw: string | undefined | null): string | null {
/** /**
* Get payment method icon component * Get payment method icon component
* *
* When iconUrl is provided, render an <img/> with that URL so custom * When icon is provided, render a safe http(s) image URL or resolve it as a
* gateway logos can be configured per-method. * react-icons component name. Invalid configured icons intentionally render
* nothing instead of falling back to the payment type.
*/ */
export function getPaymentIcon( export function getPaymentIcon(
paymentType: string | undefined, paymentType: string | undefined,
className: string = 'h-4 w-4', className: string = 'h-4 w-4',
iconUrl?: string, icon?: string,
altName?: string altName?: string
): ReactNode { ): ReactNode {
const safeIconUrl = normalizeHttpIconUrl(iconUrl) const iconValue = icon?.trim()
const safeIconUrl = normalizeHttpIconUrl(iconValue)
if (safeIconUrl) { if (safeIconUrl) {
return ( return (
<img <img
...@@ -80,6 +83,15 @@ export function getPaymentIcon( ...@@ -80,6 +83,15 @@ export function getPaymentIcon(
/> />
) )
} }
if (iconValue) {
return (
<ReactIconByName
name={iconValue}
className={className}
title={altName || paymentType || iconValue}
/>
)
}
if (!paymentType) { if (!paymentType) {
return <CreditCard className={className} /> return <CreditCard className={className} />
......
...@@ -94,11 +94,11 @@ export interface PaymentMethod { ...@@ -94,11 +94,11 @@ export interface PaymentMethod {
name: string name: string
/** Payment method type identifier */ /** Payment method type identifier */
type: string type: string
/** Optional color for UI display */ /** Legacy optional color for UI display */
color?: string color?: string
/** Minimum topup amount for this payment method */ /** Minimum topup amount for this payment method */
min_topup?: number min_topup?: number
/** Optional icon URL provided by backend (preferred over built-in icons) */ /** Optional react-icons component name or safe icon URL */
icon?: string icon?: string
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment