Implement leveling rewards feature with UI and backend support

- Added functionality to manage role rewards based on user levels in the leveling system.
- Updated the LevelingForm component to include fields for defining rewards, including validation for unique levels.
- Enhanced backend logic to handle reward data storage and retrieval, ensuring proper integration with the existing leveling configuration.
- Introduced localization keys for rewards in both English and German, improving user experience.
- Updated dashboard search index to include rewards settings for easier access.
This commit is contained in:
smueller
2026-07-23 12:41:13 +02:00
parent 3af989a83b
commit fba597a6f2
9 changed files with 480 additions and 183 deletions

View File

@@ -12,6 +12,12 @@ import {
type MultiplierEntry
} from '@/components/ui/discord-multiplier-editor';
import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
import {
entriesToRewards,
LevelRewardEditor,
rewardsToEntries,
type LevelRewardEntry
} from '@/components/ui/level-reward-editor';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PlaceholderHelp } from '@/components/ui/placeholder-help';
@@ -22,21 +28,27 @@ import type { SettingsSaveResult } from '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form';
interface LocalLevelingValue
extends Omit<LevelingConfigDashboard, 'roleMultipliers' | 'channelMultipliers'> {
extends Omit<LevelingConfigDashboard, 'roleMultipliers' | 'channelMultipliers' | 'rewards'> {
roleMultiplierEntries: MultiplierEntry[];
channelMultiplierEntries: MultiplierEntry[];
rewardEntries: LevelRewardEntry[];
}
function toLocal(config: LevelingConfigDashboard): LocalLevelingValue {
const { roleMultipliers, channelMultipliers, ...rest } = config;
const { roleMultipliers, channelMultipliers, rewards, ...rest } = config;
return {
...rest,
roleMultiplierEntries: mapToMultiplierEntries(roleMultipliers),
channelMultiplierEntries: mapToMultiplierEntries(channelMultipliers)
channelMultiplierEntries: mapToMultiplierEntries(channelMultipliers),
rewardEntries: rewardsToEntries(rewards)
};
}
async function saveLeveling(guildId: string, value: LocalLevelingValue, t: (key: string) => string): Promise<SettingsSaveResult> {
async function saveLeveling(
guildId: string,
value: LocalLevelingValue,
t: (key: string) => string
): Promise<SettingsSaveResult> {
const roleMultipliers = multiplierEntriesToMap(value.roleMultiplierEntries);
if (roleMultipliers.error) {
return { ok: false, error: t(`modulePages.leveling.errors.${roleMultipliers.error}`) };
@@ -45,17 +57,23 @@ async function saveLeveling(guildId: string, value: LocalLevelingValue, t: (key:
if (channelMultipliers.error) {
return { ok: false, error: t(`modulePages.leveling.errors.${channelMultipliers.error}`) };
}
const rewards = entriesToRewards(value.rewardEntries);
if (rewards.error) {
return { ok: false, error: t(`modulePages.leveling.errors.${rewards.error}`) };
}
const {
roleMultiplierEntries: _roleMultiplierEntries,
channelMultiplierEntries: _channelMultiplierEntries,
rewardEntries: _rewardEntries,
...rest
} = value;
const payload: LevelingConfigDashboard = {
...rest,
roleMultipliers: roleMultipliers.value,
channelMultipliers: channelMultipliers.value
channelMultipliers: channelMultipliers.value,
rewards: rewards.value
};
try {
@@ -96,214 +114,276 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-leveling-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.leveling.enabledLabel')}</Label>
<Switch
checked={value.enabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.leveling.enabledLabel')}</Label>
<Switch
checked={value.enabled}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
/>
</div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-3">
<FieldAnchor id="field-leveling-text-min">
<div className="space-y-2">
<Label>{t('modulePages.leveling.textXpMin')}</Label>
<Input
type="number"
min={1}
value={value.textXpMin}
onChange={(event) => setValue((prev) => ({ ...prev, textXpMin: Number(event.target.value) || 1 }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.leveling.textXpMin')}</Label>
<Input
type="number"
min={1}
value={value.textXpMin}
onChange={(event) =>
setValue((prev) => ({ ...prev, textXpMin: Number(event.target.value) || 1 }))
}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-leveling-text-max">
<div className="space-y-2">
<Label>{t('modulePages.leveling.textXpMax')}</Label>
<Input
type="number"
min={1}
value={value.textXpMax}
onChange={(event) => setValue((prev) => ({ ...prev, textXpMax: Number(event.target.value) || 1 }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.leveling.textXpMax')}</Label>
<Input
type="number"
min={1}
value={value.textXpMax}
onChange={(event) =>
setValue((prev) => ({ ...prev, textXpMax: Number(event.target.value) || 1 }))
}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-leveling-cooldown">
<div className="space-y-2">
<Label>{t('modulePages.leveling.textCooldownSeconds')}</Label>
<Input
type="number"
min={1}
value={value.textCooldownSeconds}
onChange={(event) =>
setValue((prev) => ({ ...prev, textCooldownSeconds: Number(event.target.value) || 1 }))
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.leveling.textCooldownSeconds')}</Label>
<Input
type="number"
min={1}
value={value.textCooldownSeconds}
onChange={(event) =>
setValue((prev) => ({
...prev,
textCooldownSeconds: Number(event.target.value) || 1
}))
}
/>
</div>
</FieldAnchor>
</div>
<FieldAnchor id="field-leveling-voice">
<div className="space-y-2">
<Label>{t('modulePages.leveling.voiceXpPerMinute')}</Label>
<Input
type="number"
min={0}
className="w-40"
value={value.voiceXpPerMinute}
onChange={(event) =>
setValue((prev) => ({ ...prev, voiceXpPerMinute: Number(event.target.value) || 0 }))
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.leveling.voiceXpPerMinute')}</Label>
<Input
type="number"
min={0}
className="w-40"
value={value.voiceXpPerMinute}
onChange={(event) =>
setValue((prev) => ({
...prev,
voiceXpPerMinute: Number(event.target.value) || 0
}))
}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-leveling-no-xp-channels">
<div className="space-y-2">
<Label>{t('modulePages.leveling.noXpChannels')}</Label>
<DiscordChannelMultiSelect
guildId={guildId}
value={value.noXpChannelIds}
onChange={(noXpChannelIds) => setValue((prev) => ({ ...prev, noXpChannelIds }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.leveling.noXpChannels')}</Label>
<DiscordChannelMultiSelect
guildId={guildId}
value={value.noXpChannelIds}
onChange={(noXpChannelIds) => setValue((prev) => ({ ...prev, noXpChannelIds }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-leveling-no-xp-roles">
<div className="space-y-2">
<Label>{t('modulePages.leveling.noXpRoles')}</Label>
<DiscordRoleMultiSelect
guildId={guildId}
value={value.noXpRoleIds}
onChange={(noXpRoleIds) => setValue((prev) => ({ ...prev, noXpRoleIds }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.leveling.noXpRoles')}</Label>
<DiscordRoleMultiSelect
guildId={guildId}
value={value.noXpRoleIds}
onChange={(noXpRoleIds) => setValue((prev) => ({ ...prev, noXpRoleIds }))}
/>
</div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-2">
<FieldAnchor id="field-leveling-role-multipliers">
<div className="space-y-2">
<Label>{t('modulePages.leveling.roleMultipliers')}</Label>
<DiscordMultiplierEditor
guildId={guildId}
kind="role"
value={value.roleMultiplierEntries}
onChange={(roleMultiplierEntries) => setValue((prev) => ({ ...prev, roleMultiplierEntries }))}
/>
<p className="text-xs text-muted-foreground">{t('modulePages.leveling.multiplierHint')}</p>
</div>
<div className="space-y-2">
<Label>{t('modulePages.leveling.roleMultipliers')}</Label>
<DiscordMultiplierEditor
guildId={guildId}
kind="role"
value={value.roleMultiplierEntries}
onChange={(roleMultiplierEntries) =>
setValue((prev) => ({ ...prev, roleMultiplierEntries }))
}
/>
<p className="text-xs text-muted-foreground">
{t('modulePages.leveling.multiplierHint')}
</p>
</div>
</FieldAnchor>
<FieldAnchor id="field-leveling-channel-multipliers">
<div className="space-y-2">
<Label>{t('modulePages.leveling.channelMultipliers')}</Label>
<DiscordMultiplierEditor
guildId={guildId}
kind="channel"
value={value.channelMultiplierEntries}
onChange={(channelMultiplierEntries) =>
setValue((prev) => ({ ...prev, channelMultiplierEntries }))
}
/>
<p className="text-xs text-muted-foreground">{t('modulePages.leveling.multiplierHint')}</p>
</div>
<div className="space-y-2">
<Label>{t('modulePages.leveling.channelMultipliers')}</Label>
<DiscordMultiplierEditor
guildId={guildId}
kind="channel"
value={value.channelMultiplierEntries}
onChange={(channelMultiplierEntries) =>
setValue((prev) => ({ ...prev, channelMultiplierEntries }))
}
/>
<p className="text-xs text-muted-foreground">
{t('modulePages.leveling.multiplierHint')}
</p>
</div>
</FieldAnchor>
</div>
</CardContent>
</Card>
<FieldAnchor id="field-leveling-levelup">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.leveling.levelUpTitle')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.leveling.levelUpMode')}</Label>
<Select
value={value.levelUpMode}
onValueChange={(mode) =>
setValue((prev) => ({ ...prev, levelUpMode: mode as LevelingConfigDashboard['levelUpMode'] }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CHANNEL">{t('modulePages.leveling.modes.CHANNEL')}</SelectItem>
<SelectItem value="DM">{t('modulePages.leveling.modes.DM')}</SelectItem>
<SelectItem value="OFF">{t('modulePages.leveling.modes.OFF')}</SelectItem>
</SelectContent>
</Select>
<Card>
<CardHeader>
<CardTitle>{t('modulePages.leveling.levelUpTitle')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.leveling.levelUpMode')}</Label>
<Select
value={value.levelUpMode}
onValueChange={(mode) =>
setValue((prev) => ({
...prev,
levelUpMode: mode as LevelingConfigDashboard['levelUpMode']
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CHANNEL">{t('modulePages.leveling.modes.CHANNEL')}</SelectItem>
<SelectItem value="DM">{t('modulePages.leveling.modes.DM')}</SelectItem>
<SelectItem value="OFF">{t('modulePages.leveling.modes.OFF')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('modulePages.leveling.levelUpChannelId')}</Label>
<DiscordChannelSelect
guildId={guildId}
value={value.levelUpChannelId}
onChange={(levelUpChannelId) =>
setValue((prev) => ({ ...prev, levelUpChannelId }))
}
/>
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.leveling.levelUpChannelId')}</Label>
<DiscordChannelSelect
<Label>{t('modulePages.leveling.levelUpMessage')}</Label>
<Textarea
value={value.levelUpMessage ?? ''}
onChange={(event) =>
setValue((prev) => ({ ...prev, levelUpMessage: event.target.value || null }))
}
placeholder="Congrats {user}, you reached level {level}!"
/>
<PlaceholderHelp preset="leveling" />
</div>
</CardContent>
</Card>
</FieldAnchor>
<FieldAnchor id="field-leveling-rewards">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.leveling.rewardsTitle')}</CardTitle>
<CardDescription>{t('modulePages.leveling.rewardsDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-leveling-stack">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-1 pr-4">
<Label>{t('modulePages.leveling.stackRewards')}</Label>
<p className="text-xs text-muted-foreground">
{t('modulePages.leveling.stackRewardsHint')}
</p>
</div>
<Switch
checked={value.stackRewards}
onCheckedChange={(checked) =>
setValue((prev) => ({ ...prev, stackRewards: checked }))
}
/>
</div>
</FieldAnchor>
<div className="space-y-2">
<div className="grid grid-cols-[7rem_minmax(0,1fr)_auto] gap-2 text-xs text-muted-foreground">
<span>{t('modulePages.leveling.rewardLevel')}</span>
<span>{t('modulePages.leveling.rewardRole')}</span>
<span className="w-9" />
</div>
<LevelRewardEditor
guildId={guildId}
value={value.levelUpChannelId}
onChange={(levelUpChannelId) => setValue((prev) => ({ ...prev, levelUpChannelId }))}
value={value.rewardEntries}
onChange={(rewardEntries) => setValue((prev) => ({ ...prev, rewardEntries }))}
/>
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.leveling.levelUpMessage')}</Label>
<Textarea
value={value.levelUpMessage ?? ''}
onChange={(event) => setValue((prev) => ({ ...prev, levelUpMessage: event.target.value || null }))}
placeholder="Congrats {user}, you reached level {level}!"
/>
<PlaceholderHelp preset="leveling" />
</div>
<FieldAnchor id="field-leveling-stack">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.leveling.stackRewards')}</Label>
<Switch
checked={value.stackRewards}
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, stackRewards: checked }))}
/>
</div>
</FieldAnchor>
</CardContent>
</Card>
</CardContent>
</Card>
</FieldAnchor>
<FieldAnchor id="field-leveling-rank-card">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.leveling.rankCardTitle')}</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.leveling.cardAccentColor')}</Label>
<div className="flex items-center gap-2">
<input
type="color"
className="size-9 rounded-md border border-input"
value={value.cardAccentColor}
onChange={(event) => setValue((prev) => ({ ...prev, cardAccentColor: event.target.value }))}
/>
<Input
value={value.cardAccentColor}
onChange={(event) => setValue((prev) => ({ ...prev, cardAccentColor: event.target.value }))}
/>
<Card>
<CardHeader>
<CardTitle>{t('modulePages.leveling.rankCardTitle')}</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.leveling.cardAccentColor')}</Label>
<div className="flex items-center gap-2">
<input
type="color"
className="size-9 rounded-md border border-input"
value={value.cardAccentColor}
onChange={(event) =>
setValue((prev) => ({ ...prev, cardAccentColor: event.target.value }))
}
/>
<Input
value={value.cardAccentColor}
onChange={(event) =>
setValue((prev) => ({ ...prev, cardAccentColor: event.target.value }))
}
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.leveling.cardBackgroundColor')}</Label>
<div className="flex items-center gap-2">
<input
type="color"
className="size-9 rounded-md border border-input"
value={value.cardBackgroundColor}
onChange={(event) => setValue((prev) => ({ ...prev, cardBackgroundColor: event.target.value }))}
/>
<Input
value={value.cardBackgroundColor}
onChange={(event) => setValue((prev) => ({ ...prev, cardBackgroundColor: event.target.value }))}
/>
<div className="space-y-2">
<Label>{t('modulePages.leveling.cardBackgroundColor')}</Label>
<div className="flex items-center gap-2">
<input
type="color"
className="size-9 rounded-md border border-input"
value={value.cardBackgroundColor}
onChange={(event) =>
setValue((prev) => ({ ...prev, cardBackgroundColor: event.target.value }))
}
/>
<Input
value={value.cardBackgroundColor}
onChange={(event) =>
setValue((prev) => ({ ...prev, cardBackgroundColor: event.target.value }))
}
/>
</div>
</div>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
</FieldAnchor>
</div>
)}