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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user