/* 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 . For commercial licensing, please contact support@quantumnous.com */ import { useState, useMemo, useEffect, useCallback, memo } from 'react' import { Pencil, Plus, Trash2, GripVertical, ChevronDown } from 'lucide-react' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card' import { Checkbox } from '@/components/ui/checkbox' import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { StaticDataTable } from '@/components/data-table' import { Dialog } from '@/components/dialog' import { safeJsonParse } from '../utils/json-parser' type GroupRatioVisualEditorProps = { groupRatio: string topupGroupRatio: string userUsableGroups: string groupGroupRatio: string autoGroups: string onChange: (field: string, value: string) => void } type SimpleGroup = { name: string value: string } type GroupPricingRow = { _id: string name: string ratio: number selectable: boolean description: string } type GroupOverride = { targetGroup: string ratio: number } const sectionCardClassName = 'relative shadow-sm ring-0 before:pointer-events-none before:absolute before:inset-0 before:rounded-xl before:border before:border-border/90' const sectionHeaderClassName = 'border-b bg-muted/20' let groupPricingIdCounter = 0 function createGroupPricingId() { groupPricingIdCounter += 1 return `gpr_${groupPricingIdCounter}` } function normalizeRatio(value: unknown): number { const parsed = Number(value) return Number.isFinite(parsed) ? parsed : 1 } function buildGroupPricingRows( groupRatio: string, userUsableGroups: string ): GroupPricingRow[] { const ratioMap = safeJsonParse>(groupRatio, { fallback: {}, context: 'group ratios', }) const usableMap = safeJsonParse>(userUsableGroups, { fallback: {}, context: 'user usable groups', }) const names = new Set([...Object.keys(ratioMap), ...Object.keys(usableMap)]) return Array.from(names).map((name) => ({ _id: createGroupPricingId(), name, ratio: normalizeRatio(ratioMap[name]), selectable: Object.prototype.hasOwnProperty.call(usableMap, name), description: String(usableMap[name] ?? ''), })) } function serializeGroupPricingRows(rows: GroupPricingRow[]) { const groupRatio: Record = {} const userUsableGroups: Record = {} for (const row of rows) { const name = row.name.trim() if (!name) continue groupRatio[name] = normalizeRatio(row.ratio) if (row.selectable) { userUsableGroups[name] = row.description } } return { GroupRatio: JSON.stringify(groupRatio, null, 2), UserUsableGroups: JSON.stringify(userUsableGroups, null, 2), } } function groupPricingSignature(rows: GroupPricingRow[]): string { const serialized = serializeGroupPricingRows(rows) return JSON.stringify({ groupRatio: safeJsonParse(serialized.GroupRatio, { fallback: {}, silent: true, }), userUsableGroups: safeJsonParse(serialized.UserUsableGroups, { fallback: {}, silent: true, }), }) } function sourceGroupPricingSignature( groupRatio: string, userUsableGroups: string ): string { return JSON.stringify({ groupRatio: safeJsonParse(groupRatio, { fallback: {}, silent: true }), userUsableGroups: safeJsonParse(userUsableGroups, { fallback: {}, silent: true, }), }) } export const GroupRatioVisualEditor = memo(function GroupRatioVisualEditor({ groupRatio, topupGroupRatio, userUsableGroups, groupGroupRatio, autoGroups, onChange, }: GroupRatioVisualEditorProps) { const { t } = useTranslation() const [simpleDialogOpen, setSimpleDialogOpen] = useState(false) const [simpleDialogType, setSimpleDialogType] = useState< 'groupRatio' | 'topupGroupRatio' | null >(null) const [simpleEditData, setSimpleEditData] = useState(null) const [autoGroupDialogOpen, setAutoGroupDialogOpen] = useState(false) const [autoGroupInput, setAutoGroupInput] = useState('') const [groupOverrideDialogOpen, setGroupOverrideDialogOpen] = useState(false) const [groupOverrideUserGroup, setGroupOverrideUserGroup] = useState< string | null >(null) const [groupOverrideEditData, setGroupOverrideEditData] = useState(null) const [userGroupDialogOpen, setUserGroupDialogOpen] = useState(false) const [userGroupInput, setUserGroupInput] = useState('') // Parse topup group ratios const topupRatioList = useMemo(() => { const map = safeJsonParse>(topupGroupRatio, { fallback: {}, context: 'topup group ratios', }) return Object.entries(map).map(([name, value]) => ({ name, value: String(value), })) }, [topupGroupRatio]) // Parse auto groups const autoGroupsList = useMemo(() => { return safeJsonParse(autoGroups, { fallback: [], context: 'auto groups', }) }, [autoGroups]) // Parse group-group ratios const groupGroupRatioList = useMemo(() => { const map = safeJsonParse>>( groupGroupRatio, { fallback: {}, context: 'group-group ratios', } ) return Object.entries(map).map(([userGroup, overrides]) => ({ userGroup, overrides: Object.entries(overrides).map(([targetGroup, ratio]) => ({ targetGroup, ratio, })), })) }, [groupGroupRatio]) // Simple group handlers (for groupRatio and topupGroupRatio) const handleSimpleAdd = (type: 'groupRatio' | 'topupGroupRatio') => { setSimpleDialogType(type) setSimpleEditData(null) setSimpleDialogOpen(true) } const handleSimpleEdit = ( type: 'groupRatio' | 'topupGroupRatio', group: SimpleGroup ) => { setSimpleDialogType(type) setSimpleEditData(group) setSimpleDialogOpen(true) } const handleSimpleSave = (name: string, value: string) => { if (!simpleDialogType) return const fieldName = simpleDialogType === 'groupRatio' ? groupRatio : topupGroupRatio const map = safeJsonParse>(fieldName, { fallback: {}, silent: true, }) if (simpleEditData && simpleEditData.name !== name) { delete map[simpleEditData.name] } map[name] = parseFloat(value) const field = simpleDialogType === 'groupRatio' ? 'GroupRatio' : 'TopupGroupRatio' onChange(field, JSON.stringify(map, null, 2)) setSimpleDialogOpen(false) } const handleSimpleDelete = ( type: 'groupRatio' | 'topupGroupRatio', name: string ) => { const fieldName = type === 'groupRatio' ? groupRatio : topupGroupRatio const map = safeJsonParse>(fieldName, { fallback: {}, silent: true, }) delete map[name] const field = type === 'groupRatio' ? 'GroupRatio' : 'TopupGroupRatio' onChange(field, JSON.stringify(map, null, 2)) } // Auto groups handlers const handleAutoGroupAdd = () => { setAutoGroupInput('') setAutoGroupDialogOpen(true) } const handleAutoGroupSave = () => { if (!autoGroupInput.trim()) return const list = [...autoGroupsList, autoGroupInput.trim()] onChange('AutoGroups', JSON.stringify(list, null, 2)) setAutoGroupDialogOpen(false) } const handleAutoGroupDelete = (index: number) => { const list = autoGroupsList.filter((_, i) => i !== index) onChange('AutoGroups', JSON.stringify(list, null, 2)) } const handleAutoGroupMove = (index: number, direction: 'up' | 'down') => { const list = [...autoGroupsList] const newIndex = direction === 'up' ? index - 1 : index + 1 if (newIndex < 0 || newIndex >= list.length) return ;[list[index], list[newIndex]] = [list[newIndex], list[index]] onChange('AutoGroups', JSON.stringify(list, null, 2)) } // Group-group ratio handlers const handleUserGroupAdd = () => { setUserGroupInput('') setUserGroupDialogOpen(true) } const handleUserGroupSave = () => { if (!userGroupInput.trim()) return const map = safeJsonParse>>( groupGroupRatio, { fallback: {}, silent: true, } ) if (!map[userGroupInput.trim()]) { map[userGroupInput.trim()] = {} } onChange('GroupGroupRatio', JSON.stringify(map, null, 2)) setUserGroupDialogOpen(false) } const handleUserGroupDelete = (userGroup: string) => { const map = safeJsonParse>>( groupGroupRatio, { fallback: {}, silent: true, } ) delete map[userGroup] onChange('GroupGroupRatio', JSON.stringify(map, null, 2)) } const handleOverrideAdd = (userGroup: string) => { setGroupOverrideUserGroup(userGroup) setGroupOverrideEditData(null) setGroupOverrideDialogOpen(true) } const handleOverrideEdit = (userGroup: string, override: GroupOverride) => { setGroupOverrideUserGroup(userGroup) setGroupOverrideEditData(override) setGroupOverrideDialogOpen(true) } const handleOverrideSave = ( targetGroup: string, ratio: number, oldTargetGroup?: string ) => { if (!groupOverrideUserGroup) return const map = safeJsonParse>>( groupGroupRatio, { fallback: {}, silent: true, } ) if (!map[groupOverrideUserGroup]) { map[groupOverrideUserGroup] = {} } if (oldTargetGroup && oldTargetGroup !== targetGroup) { delete map[groupOverrideUserGroup][oldTargetGroup] } map[groupOverrideUserGroup][targetGroup] = ratio onChange('GroupGroupRatio', JSON.stringify(map, null, 2)) setGroupOverrideDialogOpen(false) } const handleOverrideDelete = (userGroup: string, targetGroup: string) => { const map = safeJsonParse>>( groupGroupRatio, { fallback: {}, silent: true, } ) if (map[userGroup]) { delete map[userGroup][targetGroup] if (Object.keys(map[userGroup]).length === 0) { delete map[userGroup] } } onChange('GroupGroupRatio', JSON.stringify(map, null, 2)) } return ( {/* Topup Group Ratios */} {t('Top-up group ratios')} {t('Multipliers for recharge pricing based on user groups.')} handleSimpleAdd('topupGroupRatio')} size='sm' > {t('Add group')} {topupRatioList.length > 0 && ( group.name} columns={[ { id: 'group', header: t('Group name'), cellClassName: 'font-medium', cell: (group) => group.name, }, { id: 'multiplier', header: t('Multiplier'), cell: (group) => group.value, }, { id: 'actions', header: t('Actions'), className: 'text-right', cellClassName: 'text-right', cell: (group) => ( handleSimpleEdit('topupGroupRatio', group) } > handleSimpleDelete('topupGroupRatio', group.name) } > ), }, ]} /> )} {/* Inter-group ratio overrides */} {t('Inter-group ratio overrides')} {t( 'Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using "edit_this" group tokens.' )} {t('Add user group')} {groupGroupRatioList.length > 0 && ( {groupGroupRatioList.map((userGroupData) => ( } > {userGroupData.userGroup} {t('{{count}} override', { count: userGroupData.overrides.length, })} handleOverrideAdd(userGroupData.userGroup) } > handleUserGroupDelete(userGroupData.userGroup) } > {userGroupData.overrides.length > 0 && ( override.targetGroup} columns={[ { id: 'target-group', header: t('Target group'), cellClassName: 'font-medium', cell: (override) => override.targetGroup, }, { id: 'ratio', header: t('Ratio'), cell: (override) => override.ratio, }, { id: 'actions', header: t('Actions'), className: 'text-right', cellClassName: 'text-right', cell: (override) => ( handleOverrideEdit( userGroupData.userGroup, override ) } > handleOverrideDelete( userGroupData.userGroup, override.targetGroup ) } > ), }, ]} /> )} ))} )} {/* Auto Groups */} {t('Auto assignment order')} {t( 'Priority order for automatic group assignment. New tokens rotate through this list.' )} {t('Add group')} {autoGroupsList.length > 0 && ( {autoGroupsList.map((group, index) => ( {group} handleAutoGroupMove(index, 'up')} > ↑ handleAutoGroupMove(index, 'down')} > ↓ handleAutoGroupDelete(index)} > ))} )} {/* Simple Group Dialog */} {/* Auto Group Dialog */} setAutoGroupDialogOpen(false)} > {t('Cancel')} {t('Add')} > } > {t('Group identifier')} setAutoGroupInput(e.target.value)} placeholder={t('default')} /> {/* User Group Dialog */} setUserGroupDialogOpen(false)} > {t('Cancel')} {t('Add')} > } > {t('User group name')} setUserGroupInput(e.target.value)} placeholder={t('vip')} /> {/* Group Override Dialog */} ) }) type GroupPricingTableProps = { groupRatio: string userUsableGroups: string onChange: (field: string, value: string) => void } function GroupPricingTable({ groupRatio, userUsableGroups, onChange, }: GroupPricingTableProps) { const { t } = useTranslation() const [rows, setRows] = useState(() => buildGroupPricingRows(groupRatio, userUsableGroups) ) useEffect(() => { const incomingSignature = sourceGroupPricingSignature( groupRatio, userUsableGroups ) setRows((currentRows) => { if (groupPricingSignature(currentRows) === incomingSignature) { return currentRows } return buildGroupPricingRows(groupRatio, userUsableGroups) }) }, [groupRatio, userUsableGroups]) const emitRows = useCallback( (nextRows: GroupPricingRow[]) => { setRows(nextRows) const serialized = serializeGroupPricingRows(nextRows) onChange('GroupRatio', serialized.GroupRatio) onChange('UserUsableGroups', serialized.UserUsableGroups) }, [onChange] ) const updateRow = useCallback( ( id: string, field: Exclude, value: string | number | boolean ) => { emitRows( rows.map((row) => (row._id === id ? { ...row, [field]: value } : row)) ) }, [emitRows, rows] ) const addRow = useCallback(() => { const existingNames = new Set(rows.map((row) => row.name)) let index = 1 let name = `group_${index}` while (existingNames.has(name)) { index += 1 name = `group_${index}` } emitRows([ ...rows, { _id: createGroupPricingId(), name, ratio: 1, selectable: true, description: '', }, ]) }, [emitRows, rows]) const removeRow = useCallback( (id: string) => { emitRows(rows.filter((row) => row._id !== id)) }, [emitRows, rows] ) const duplicateNames = useMemo(() => { const counts = new Map() for (const row of rows) { const name = row.name.trim() if (!name) continue counts.set(name, (counts.get(name) ?? 0) + 1) } return Array.from(counts.entries()) .filter(([, count]) => count > 1) .map(([name]) => name) }, [rows]) return ( {t('Pricing groups')} {t( 'Edit billing ratios and user-selectable groups in one table.' )} {t('Add group')} row._id} emptyClassName='text-muted-foreground h-20 text-sm' emptyContent={t('No groups yet. Add a group to get started.')} columns={[ { id: 'group', header: t('Group name'), className: 'min-w-40', cell: (row) => ( updateRow(row._id, 'name', event.target.value) } aria-invalid={duplicateNames.includes(row.name.trim())} /> ), }, { id: 'ratio', header: t('Ratio'), className: 'w-28', cell: (row) => ( updateRow( row._id, 'ratio', normalizeRatio(event.target.value) ) } /> ), }, { id: 'selectable', header: t('User selectable'), className: 'w-28 text-center', cell: (row) => ( updateRow(row._id, 'selectable', checked === true) } aria-label={t('User selectable')} /> ), }, { id: 'description', header: t('Description'), className: 'min-w-56', cell: (row) => row.selectable ? ( updateRow(row._id, 'description', event.target.value) } /> ) : ( - ), }, { id: 'actions', header: t('Actions'), className: 'w-16 text-right', cellClassName: 'text-right', cell: (row) => ( removeRow(row._id)} aria-label={t('Delete')} > ), }, ]} /> {duplicateNames.length > 0 && ( {t('Duplicate group names: {{names}}', { names: duplicateNames.join(', '), })} )} ) } // Simple Group Dialog Component type SimpleGroupDialogProps = { open: boolean onOpenChange: (open: boolean) => void onSave: (name: string, value: string) => void editData: SimpleGroup | null type: 'groupRatio' | 'topupGroupRatio' | null } function SimpleGroupDialog({ open, onOpenChange, onSave, editData, type, }: SimpleGroupDialogProps) { const { t } = useTranslation() const [name, setName] = useState('') const [value, setValue] = useState('') const title = type === 'groupRatio' ? t('group ratio') : t('top-up ratio') useEffect(() => { if (!open) { setName('') setValue('') return } setName(editData?.name ?? '') setValue(editData?.value ?? '') }, [editData, open]) const handleSave = () => { if (!name.trim() || !value.trim()) return onSave(name.trim(), value.trim()) setName('') setValue('') } return ( onOpenChange(false)}> {t('Cancel')} {editData ? t('Update') : t('Add')} > } > {t('Group name')} setName(e.target.value)} placeholder={t('default')} disabled={!!editData} /> {t('Ratio')} { const val = e.target.value if (val === '' || !isNaN(parseFloat(val))) { setValue(val) } }} placeholder='1.0' /> ) } // Group Override Dialog Component type GroupOverrideDialogProps = { open: boolean onOpenChange: (open: boolean) => void onSave: (targetGroup: string, ratio: number, oldTargetGroup?: string) => void editData: GroupOverride | null userGroup: string | null } function GroupOverrideDialog({ open, onOpenChange, onSave, editData, userGroup, }: GroupOverrideDialogProps) { const { t } = useTranslation() const [targetGroup, setTargetGroup] = useState('') const [ratio, setRatio] = useState('') useEffect(() => { if (!open) { setTargetGroup('') setRatio('') return } setTargetGroup(editData?.targetGroup ?? '') setRatio(editData ? String(editData.ratio) : '') }, [editData, open]) const handleSave = () => { if (!targetGroup.trim() || !ratio.trim()) return const parsedRatio = parseFloat(ratio) if (isNaN(parsedRatio)) return onSave(targetGroup.trim(), parsedRatio, editData?.targetGroup) setTargetGroup('') setRatio('') } return ( onOpenChange(false)}> {t('Cancel')} {editData ? t('Update') : t('Add')} > } > {t('Target group')} setTargetGroup(e.target.value)} placeholder={t('edit_this')} disabled={!!editData} /> {t('The token group that will have a custom ratio')} {t('Ratio')} { const val = e.target.value if (val === '' || !isNaN(parseFloat(val))) { setRatio(val) } }} placeholder='0.9' /> {t('Multiplier applied when {{userGroup}} uses {{targetGroup}}', { userGroup: userGroup || t('this user group'), targetGroup: targetGroup || t('this token group'), })} ) }
{t('Duplicate group names: {{names}}', { names: duplicateNames.join(', '), })}
{t('The token group that will have a custom ratio')}
{t('Multiplier applied when {{userGroup}} uses {{targetGroup}}', { userGroup: userGroup || t('this user group'), targetGroup: targetGroup || t('this token group'), })}