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:
@@ -273,7 +273,7 @@ export async function applyLevelRewards(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const reward = rewards.find((entry) => entry.level === newLevel);
|
const reward = [...rewards].reverse().find((entry) => entry.level <= newLevel);
|
||||||
const removable = rewards
|
const removable = rewards
|
||||||
.map((entry) => entry.roleId)
|
.map((entry) => entry.roleId)
|
||||||
.filter((roleId) => member.roles.cache.has(roleId) && rewardRoleIds.has(roleId));
|
.filter((roleId) => member.roles.cache.has(roleId) && rewardRoleIds.has(roleId));
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ import {
|
|||||||
type MultiplierEntry
|
type MultiplierEntry
|
||||||
} from '@/components/ui/discord-multiplier-editor';
|
} from '@/components/ui/discord-multiplier-editor';
|
||||||
import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
|
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 { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { PlaceholderHelp } from '@/components/ui/placeholder-help';
|
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';
|
import { SettingsForm } from '@/components/settings/settings-form';
|
||||||
|
|
||||||
interface LocalLevelingValue
|
interface LocalLevelingValue
|
||||||
extends Omit<LevelingConfigDashboard, 'roleMultipliers' | 'channelMultipliers'> {
|
extends Omit<LevelingConfigDashboard, 'roleMultipliers' | 'channelMultipliers' | 'rewards'> {
|
||||||
roleMultiplierEntries: MultiplierEntry[];
|
roleMultiplierEntries: MultiplierEntry[];
|
||||||
channelMultiplierEntries: MultiplierEntry[];
|
channelMultiplierEntries: MultiplierEntry[];
|
||||||
|
rewardEntries: LevelRewardEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function toLocal(config: LevelingConfigDashboard): LocalLevelingValue {
|
function toLocal(config: LevelingConfigDashboard): LocalLevelingValue {
|
||||||
const { roleMultipliers, channelMultipliers, ...rest } = config;
|
const { roleMultipliers, channelMultipliers, rewards, ...rest } = config;
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
roleMultiplierEntries: mapToMultiplierEntries(roleMultipliers),
|
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);
|
const roleMultipliers = multiplierEntriesToMap(value.roleMultiplierEntries);
|
||||||
if (roleMultipliers.error) {
|
if (roleMultipliers.error) {
|
||||||
return { ok: false, error: t(`modulePages.leveling.errors.${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) {
|
if (channelMultipliers.error) {
|
||||||
return { ok: false, error: t(`modulePages.leveling.errors.${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 {
|
const {
|
||||||
roleMultiplierEntries: _roleMultiplierEntries,
|
roleMultiplierEntries: _roleMultiplierEntries,
|
||||||
channelMultiplierEntries: _channelMultiplierEntries,
|
channelMultiplierEntries: _channelMultiplierEntries,
|
||||||
|
rewardEntries: _rewardEntries,
|
||||||
...rest
|
...rest
|
||||||
} = value;
|
} = value;
|
||||||
|
|
||||||
const payload: LevelingConfigDashboard = {
|
const payload: LevelingConfigDashboard = {
|
||||||
...rest,
|
...rest,
|
||||||
roleMultipliers: roleMultipliers.value,
|
roleMultipliers: roleMultipliers.value,
|
||||||
channelMultipliers: channelMultipliers.value
|
channelMultipliers: channelMultipliers.value,
|
||||||
|
rewards: rewards.value
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -96,214 +114,276 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<FieldAnchor id="field-leveling-enabled">
|
<FieldAnchor id="field-leveling-enabled">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
<Label>{t('modulePages.leveling.enabledLabel')}</Label>
|
<Label>{t('modulePages.leveling.enabledLabel')}</Label>
|
||||||
<Switch
|
<Switch
|
||||||
checked={value.enabled}
|
checked={value.enabled}
|
||||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
<FieldAnchor id="field-leveling-text-min">
|
<FieldAnchor id="field-leveling-text-min">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.leveling.textXpMin')}</Label>
|
<Label>{t('modulePages.leveling.textXpMin')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={value.textXpMin}
|
value={value.textXpMin}
|
||||||
onChange={(event) => setValue((prev) => ({ ...prev, textXpMin: Number(event.target.value) || 1 }))}
|
onChange={(event) =>
|
||||||
/>
|
setValue((prev) => ({ ...prev, textXpMin: Number(event.target.value) || 1 }))
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
<FieldAnchor id="field-leveling-text-max">
|
<FieldAnchor id="field-leveling-text-max">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.leveling.textXpMax')}</Label>
|
<Label>{t('modulePages.leveling.textXpMax')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={value.textXpMax}
|
value={value.textXpMax}
|
||||||
onChange={(event) => setValue((prev) => ({ ...prev, textXpMax: Number(event.target.value) || 1 }))}
|
onChange={(event) =>
|
||||||
/>
|
setValue((prev) => ({ ...prev, textXpMax: Number(event.target.value) || 1 }))
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
<FieldAnchor id="field-leveling-cooldown">
|
<FieldAnchor id="field-leveling-cooldown">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.leveling.textCooldownSeconds')}</Label>
|
<Label>{t('modulePages.leveling.textCooldownSeconds')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
value={value.textCooldownSeconds}
|
value={value.textCooldownSeconds}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setValue((prev) => ({ ...prev, textCooldownSeconds: Number(event.target.value) || 1 }))
|
setValue((prev) => ({
|
||||||
}
|
...prev,
|
||||||
/>
|
textCooldownSeconds: Number(event.target.value) || 1
|
||||||
</div>
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FieldAnchor id="field-leveling-voice">
|
<FieldAnchor id="field-leveling-voice">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.leveling.voiceXpPerMinute')}</Label>
|
<Label>{t('modulePages.leveling.voiceXpPerMinute')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
className="w-40"
|
className="w-40"
|
||||||
value={value.voiceXpPerMinute}
|
value={value.voiceXpPerMinute}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setValue((prev) => ({ ...prev, voiceXpPerMinute: Number(event.target.value) || 0 }))
|
setValue((prev) => ({
|
||||||
}
|
...prev,
|
||||||
/>
|
voiceXpPerMinute: Number(event.target.value) || 0
|
||||||
</div>
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
|
|
||||||
<FieldAnchor id="field-leveling-no-xp-channels">
|
<FieldAnchor id="field-leveling-no-xp-channels">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.leveling.noXpChannels')}</Label>
|
<Label>{t('modulePages.leveling.noXpChannels')}</Label>
|
||||||
<DiscordChannelMultiSelect
|
<DiscordChannelMultiSelect
|
||||||
guildId={guildId}
|
guildId={guildId}
|
||||||
value={value.noXpChannelIds}
|
value={value.noXpChannelIds}
|
||||||
onChange={(noXpChannelIds) => setValue((prev) => ({ ...prev, noXpChannelIds }))}
|
onChange={(noXpChannelIds) => setValue((prev) => ({ ...prev, noXpChannelIds }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
<FieldAnchor id="field-leveling-no-xp-roles">
|
<FieldAnchor id="field-leveling-no-xp-roles">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.leveling.noXpRoles')}</Label>
|
<Label>{t('modulePages.leveling.noXpRoles')}</Label>
|
||||||
<DiscordRoleMultiSelect
|
<DiscordRoleMultiSelect
|
||||||
guildId={guildId}
|
guildId={guildId}
|
||||||
value={value.noXpRoleIds}
|
value={value.noXpRoleIds}
|
||||||
onChange={(noXpRoleIds) => setValue((prev) => ({ ...prev, noXpRoleIds }))}
|
onChange={(noXpRoleIds) => setValue((prev) => ({ ...prev, noXpRoleIds }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<FieldAnchor id="field-leveling-role-multipliers">
|
<FieldAnchor id="field-leveling-role-multipliers">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.leveling.roleMultipliers')}</Label>
|
<Label>{t('modulePages.leveling.roleMultipliers')}</Label>
|
||||||
<DiscordMultiplierEditor
|
<DiscordMultiplierEditor
|
||||||
guildId={guildId}
|
guildId={guildId}
|
||||||
kind="role"
|
kind="role"
|
||||||
value={value.roleMultiplierEntries}
|
value={value.roleMultiplierEntries}
|
||||||
onChange={(roleMultiplierEntries) => setValue((prev) => ({ ...prev, roleMultiplierEntries }))}
|
onChange={(roleMultiplierEntries) =>
|
||||||
/>
|
setValue((prev) => ({ ...prev, roleMultiplierEntries }))
|
||||||
<p className="text-xs text-muted-foreground">{t('modulePages.leveling.multiplierHint')}</p>
|
}
|
||||||
</div>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('modulePages.leveling.multiplierHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
<FieldAnchor id="field-leveling-channel-multipliers">
|
<FieldAnchor id="field-leveling-channel-multipliers">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.leveling.channelMultipliers')}</Label>
|
<Label>{t('modulePages.leveling.channelMultipliers')}</Label>
|
||||||
<DiscordMultiplierEditor
|
<DiscordMultiplierEditor
|
||||||
guildId={guildId}
|
guildId={guildId}
|
||||||
kind="channel"
|
kind="channel"
|
||||||
value={value.channelMultiplierEntries}
|
value={value.channelMultiplierEntries}
|
||||||
onChange={(channelMultiplierEntries) =>
|
onChange={(channelMultiplierEntries) =>
|
||||||
setValue((prev) => ({ ...prev, channelMultiplierEntries }))
|
setValue((prev) => ({ ...prev, channelMultiplierEntries }))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">{t('modulePages.leveling.multiplierHint')}</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
</div>
|
{t('modulePages.leveling.multiplierHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<FieldAnchor id="field-leveling-levelup">
|
<FieldAnchor id="field-leveling-levelup">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('modulePages.leveling.levelUpTitle')}</CardTitle>
|
<CardTitle>{t('modulePages.leveling.levelUpTitle')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.leveling.levelUpMode')}</Label>
|
<Label>{t('modulePages.leveling.levelUpMode')}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={value.levelUpMode}
|
value={value.levelUpMode}
|
||||||
onValueChange={(mode) =>
|
onValueChange={(mode) =>
|
||||||
setValue((prev) => ({ ...prev, levelUpMode: mode as LevelingConfigDashboard['levelUpMode'] }))
|
setValue((prev) => ({
|
||||||
}
|
...prev,
|
||||||
>
|
levelUpMode: mode as LevelingConfigDashboard['levelUpMode']
|
||||||
<SelectTrigger>
|
}))
|
||||||
<SelectValue />
|
}
|
||||||
</SelectTrigger>
|
>
|
||||||
<SelectContent>
|
<SelectTrigger>
|
||||||
<SelectItem value="CHANNEL">{t('modulePages.leveling.modes.CHANNEL')}</SelectItem>
|
<SelectValue />
|
||||||
<SelectItem value="DM">{t('modulePages.leveling.modes.DM')}</SelectItem>
|
</SelectTrigger>
|
||||||
<SelectItem value="OFF">{t('modulePages.leveling.modes.OFF')}</SelectItem>
|
<SelectContent>
|
||||||
</SelectContent>
|
<SelectItem value="CHANNEL">{t('modulePages.leveling.modes.CHANNEL')}</SelectItem>
|
||||||
</Select>
|
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.leveling.levelUpChannelId')}</Label>
|
<Label>{t('modulePages.leveling.levelUpMessage')}</Label>
|
||||||
<DiscordChannelSelect
|
<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}
|
guildId={guildId}
|
||||||
value={value.levelUpChannelId}
|
value={value.rewardEntries}
|
||||||
onChange={(levelUpChannelId) => setValue((prev) => ({ ...prev, levelUpChannelId }))}
|
onChange={(rewardEntries) => setValue((prev) => ({ ...prev, rewardEntries }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
<div className="space-y-2">
|
</Card>
|
||||||
<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>
|
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
|
|
||||||
<FieldAnchor id="field-leveling-rank-card">
|
<FieldAnchor id="field-leveling-rank-card">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('modulePages.leveling.rankCardTitle')}</CardTitle>
|
<CardTitle>{t('modulePages.leveling.rankCardTitle')}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.leveling.cardAccentColor')}</Label>
|
<Label>{t('modulePages.leveling.cardAccentColor')}</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
className="size-9 rounded-md border border-input"
|
className="size-9 rounded-md border border-input"
|
||||||
value={value.cardAccentColor}
|
value={value.cardAccentColor}
|
||||||
onChange={(event) => setValue((prev) => ({ ...prev, cardAccentColor: event.target.value }))}
|
onChange={(event) =>
|
||||||
/>
|
setValue((prev) => ({ ...prev, cardAccentColor: event.target.value }))
|
||||||
<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>
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<Label>{t('modulePages.leveling.cardBackgroundColor')}</Label>
|
||||||
<Label>{t('modulePages.leveling.cardBackgroundColor')}</Label>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<input
|
||||||
<input
|
type="color"
|
||||||
type="color"
|
className="size-9 rounded-md border border-input"
|
||||||
className="size-9 rounded-md border border-input"
|
value={value.cardBackgroundColor}
|
||||||
value={value.cardBackgroundColor}
|
onChange={(event) =>
|
||||||
onChange={(event) => setValue((prev) => ({ ...prev, cardBackgroundColor: event.target.value }))}
|
setValue((prev) => ({ ...prev, cardBackgroundColor: event.target.value }))
|
||||||
/>
|
}
|
||||||
<Input
|
/>
|
||||||
value={value.cardBackgroundColor}
|
<Input
|
||||||
onChange={(event) => setValue((prev) => ({ ...prev, cardBackgroundColor: event.target.value }))}
|
value={value.cardBackgroundColor}
|
||||||
/>
|
onChange={(event) =>
|
||||||
|
setValue((prev) => ({ ...prev, cardBackgroundColor: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</CardContent>
|
</Card>
|
||||||
</Card>
|
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
119
apps/webui/src/components/ui/level-reward-editor.tsx
Normal file
119
apps/webui/src/components/ui/level-reward-editor.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { DiscordRoleSelect } from '@/components/ui/discord-role-select';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import type { LevelRewardDashboard } from '@nexumi/shared';
|
||||||
|
|
||||||
|
export interface LevelRewardEntry extends LevelRewardDashboard {
|
||||||
|
clientKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rewardsToEntries(rewards: LevelRewardDashboard[]): LevelRewardEntry[] {
|
||||||
|
return rewards.map((reward) => ({
|
||||||
|
clientKey: `lvl-${reward.level}-${reward.roleId}`,
|
||||||
|
level: reward.level,
|
||||||
|
roleId: reward.roleId
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entriesToRewards(
|
||||||
|
entries: LevelRewardEntry[]
|
||||||
|
): { value: LevelRewardDashboard[]; error?: string } {
|
||||||
|
const value: LevelRewardDashboard[] = [];
|
||||||
|
const levels = new Set<number>();
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.roleId) {
|
||||||
|
return { value: [], error: 'rewardMissingRole' };
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(entry.level) || entry.level < 1) {
|
||||||
|
return { value: [], error: 'rewardInvalidLevel' };
|
||||||
|
}
|
||||||
|
if (levels.has(entry.level)) {
|
||||||
|
return { value: [], error: 'rewardDuplicateLevel' };
|
||||||
|
}
|
||||||
|
levels.add(entry.level);
|
||||||
|
value.push({ level: entry.level, roleId: entry.roleId });
|
||||||
|
}
|
||||||
|
|
||||||
|
value.sort((a, b) => a.level - b.level);
|
||||||
|
return { value };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LevelRewardEditorProps {
|
||||||
|
guildId: string;
|
||||||
|
value: LevelRewardEntry[];
|
||||||
|
onChange: (next: LevelRewardEntry[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LevelRewardEditor({ guildId, value, onChange }: LevelRewardEditorProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
function updateEntry(
|
||||||
|
clientKey: string,
|
||||||
|
patch: Partial<Pick<LevelRewardEntry, 'level' | 'roleId'>>
|
||||||
|
) {
|
||||||
|
onChange(value.map((entry) => (entry.clientKey === clientKey ? { ...entry, ...patch } : entry)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{value.map((entry) => (
|
||||||
|
<div key={entry.clientKey} className="flex items-end gap-2">
|
||||||
|
<div className="w-28 space-y-2">
|
||||||
|
<Label className="sr-only">{t('modulePages.leveling.rewardLevel')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10000}
|
||||||
|
value={entry.level}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateEntry(entry.clientKey, { level: Number(event.target.value) || 1 })
|
||||||
|
}
|
||||||
|
aria-label={t('modulePages.leveling.rewardLevel')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<Label className="sr-only">{t('modulePages.leveling.rewardRole')}</Label>
|
||||||
|
<DiscordRoleSelect
|
||||||
|
guildId={guildId}
|
||||||
|
value={entry.roleId}
|
||||||
|
onChange={(roleId) => updateEntry(entry.clientKey, { roleId })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t('common.remove')}
|
||||||
|
onClick={() => onChange(value.filter((item) => item.clientKey !== entry.clientKey))}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
onChange([
|
||||||
|
...value,
|
||||||
|
{
|
||||||
|
clientKey: `new-reward-${Date.now()}`,
|
||||||
|
level: value.length > 0 ? Math.max(...value.map((entry) => entry.level)) + 1 : 5,
|
||||||
|
roleId: ''
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
{t('modulePages.leveling.addReward')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -499,6 +499,13 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
|
|||||||
href: 'leveling',
|
href: 'leveling',
|
||||||
hash: 'field-leveling-stack'
|
hash: 'field-leveling-stack'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'setting-leveling-rewards',
|
||||||
|
kind: 'setting',
|
||||||
|
labelKey: 'modulePages.leveling.rewardsTitle',
|
||||||
|
href: 'leveling',
|
||||||
|
hash: 'field-leveling-rewards'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'setting-leveling-rank-card',
|
id: 'setting-leveling-rank-card',
|
||||||
kind: 'setting',
|
kind: 'setting',
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { LevelingConfigDashboard, LevelingConfigDashboardPatch } from '@nexumi/shared';
|
import {
|
||||||
|
hasUniqueRewardLevels,
|
||||||
|
type LevelingConfigDashboard,
|
||||||
|
type LevelingConfigDashboardPatch,
|
||||||
|
type LevelRewardDashboard
|
||||||
|
} from '@nexumi/shared';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
import { prisma } from '../prisma';
|
import { prisma } from '../prisma';
|
||||||
|
|
||||||
@@ -21,7 +26,10 @@ function toMultiplierMap(raw: unknown): Record<string, number> {
|
|||||||
return Object.fromEntries(entries);
|
return Object.fromEntries(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDashboard(config: Awaited<ReturnType<typeof ensureLevelingConfig>>): LevelingConfigDashboard {
|
function toDashboard(
|
||||||
|
config: Awaited<ReturnType<typeof ensureLevelingConfig>>,
|
||||||
|
rewards: LevelRewardDashboard[]
|
||||||
|
): LevelingConfigDashboard {
|
||||||
return {
|
return {
|
||||||
enabled: config.enabled,
|
enabled: config.enabled,
|
||||||
textXpMin: config.textXpMin,
|
textXpMin: config.textXpMin,
|
||||||
@@ -36,14 +44,25 @@ function toDashboard(config: Awaited<ReturnType<typeof ensureLevelingConfig>>):
|
|||||||
levelUpChannelId: config.levelUpChannelId ?? '',
|
levelUpChannelId: config.levelUpChannelId ?? '',
|
||||||
levelUpMessage: config.levelUpMessage,
|
levelUpMessage: config.levelUpMessage,
|
||||||
stackRewards: config.stackRewards,
|
stackRewards: config.stackRewards,
|
||||||
|
rewards,
|
||||||
cardAccentColor: config.cardAccentColor,
|
cardAccentColor: config.cardAccentColor,
|
||||||
cardBackgroundColor: config.cardBackgroundColor
|
cardBackgroundColor: config.cardBackgroundColor
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadRewards(guildId: string): Promise<LevelRewardDashboard[]> {
|
||||||
|
const rows = await prisma.levelReward.findMany({
|
||||||
|
where: { guildId },
|
||||||
|
orderBy: { level: 'asc' },
|
||||||
|
select: { level: true, roleId: true }
|
||||||
|
});
|
||||||
|
return rows.map((row) => ({ level: row.level, roleId: row.roleId }));
|
||||||
|
}
|
||||||
|
|
||||||
export async function getLevelingDashboard(guildId: string): Promise<LevelingConfigDashboard> {
|
export async function getLevelingDashboard(guildId: string): Promise<LevelingConfigDashboard> {
|
||||||
const config = await ensureLevelingConfig(guildId);
|
const config = await ensureLevelingConfig(guildId);
|
||||||
return toDashboard(config);
|
const rewards = await loadRewards(guildId);
|
||||||
|
return toDashboard(config, rewards);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateLevelingDashboard(
|
export async function updateLevelingDashboard(
|
||||||
@@ -52,7 +71,11 @@ export async function updateLevelingDashboard(
|
|||||||
): Promise<LevelingConfigDashboard> {
|
): Promise<LevelingConfigDashboard> {
|
||||||
await ensureLevelingConfig(guildId);
|
await ensureLevelingConfig(guildId);
|
||||||
|
|
||||||
const { levelUpChannelId, roleMultipliers, channelMultipliers, ...rest } = patch;
|
if (patch.rewards !== undefined && !hasUniqueRewardLevels(patch.rewards)) {
|
||||||
|
throw new Error('Duplicate reward levels are not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { levelUpChannelId, roleMultipliers, channelMultipliers, rewards, ...rest } = patch;
|
||||||
const data: Prisma.LevelingConfigUpdateInput = { ...rest };
|
const data: Prisma.LevelingConfigUpdateInput = { ...rest };
|
||||||
if (levelUpChannelId !== undefined) {
|
if (levelUpChannelId !== undefined) {
|
||||||
data.levelUpChannelId = levelUpChannelId === '' ? null : levelUpChannelId;
|
data.levelUpChannelId = levelUpChannelId === '' ? null : levelUpChannelId;
|
||||||
@@ -64,6 +87,23 @@ export async function updateLevelingDashboard(
|
|||||||
data.channelMultipliers = channelMultipliers as Prisma.InputJsonValue;
|
data.channelMultipliers = channelMultipliers as Prisma.InputJsonValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await prisma.levelingConfig.update({ where: { guildId }, data });
|
await prisma.$transaction(async (tx) => {
|
||||||
return toDashboard(updated);
|
if (Object.keys(data).length > 0) {
|
||||||
|
await tx.levelingConfig.update({ where: { guildId }, data });
|
||||||
|
}
|
||||||
|
if (rewards !== undefined) {
|
||||||
|
await tx.levelReward.deleteMany({ where: { guildId } });
|
||||||
|
if (rewards.length > 0) {
|
||||||
|
await tx.levelReward.createMany({
|
||||||
|
data: rewards.map((reward) => ({
|
||||||
|
guildId,
|
||||||
|
level: reward.level,
|
||||||
|
roleId: reward.roleId
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return getLevelingDashboard(guildId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -522,7 +522,10 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"multiplierMissingId": "Jeder Multiplikator braucht eine Rolle bzw. einen Kanal.",
|
"multiplierMissingId": "Jeder Multiplikator braucht eine Rolle bzw. einen Kanal.",
|
||||||
"multiplierInvalid": "Multiplikatoren müssen eine positive Zahl sein.",
|
"multiplierInvalid": "Multiplikatoren müssen eine positive Zahl sein.",
|
||||||
"multiplierDuplicate": "Jede Rolle bzw. jeder Kanal darf nur einmal vorkommen."
|
"multiplierDuplicate": "Jede Rolle bzw. jeder Kanal darf nur einmal vorkommen.",
|
||||||
|
"rewardMissingRole": "Jede Belohnung braucht eine Rolle.",
|
||||||
|
"rewardInvalidLevel": "Das Level muss eine ganze Zahl ab 1 sein.",
|
||||||
|
"rewardDuplicateLevel": "Jedes Level darf nur einmal eine Belohnung haben."
|
||||||
},
|
},
|
||||||
"levelUpTitle": "Level-Up-Nachricht",
|
"levelUpTitle": "Level-Up-Nachricht",
|
||||||
"levelUpMode": "Modus",
|
"levelUpMode": "Modus",
|
||||||
@@ -533,7 +536,13 @@
|
|||||||
},
|
},
|
||||||
"levelUpChannelId": "Level-Up-Kanal",
|
"levelUpChannelId": "Level-Up-Kanal",
|
||||||
"levelUpMessage": "Nachrichtentext",
|
"levelUpMessage": "Nachrichtentext",
|
||||||
|
"rewardsTitle": "Rollen-Belohnungen",
|
||||||
|
"rewardsDescription": "Lege fest, ab welchem Level welche Rolle vergeben wird.",
|
||||||
|
"rewardLevel": "Level",
|
||||||
|
"rewardRole": "Rolle",
|
||||||
|
"addReward": "Belohnung hinzufügen",
|
||||||
"stackRewards": "Rollen-Belohnungen stapeln",
|
"stackRewards": "Rollen-Belohnungen stapeln",
|
||||||
|
"stackRewardsHint": "An = alle erreichten Belohnungsrollen behalten. Aus = nur die Rolle des aktuellen Levels.",
|
||||||
"rankCardTitle": "Rang-Karte",
|
"rankCardTitle": "Rang-Karte",
|
||||||
"cardAccentColor": "Akzentfarbe",
|
"cardAccentColor": "Akzentfarbe",
|
||||||
"cardBackgroundColor": "Hintergrundfarbe"
|
"cardBackgroundColor": "Hintergrundfarbe"
|
||||||
|
|||||||
@@ -522,7 +522,10 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"multiplierMissingId": "Every multiplier needs a role or channel.",
|
"multiplierMissingId": "Every multiplier needs a role or channel.",
|
||||||
"multiplierInvalid": "Multipliers must be a positive number.",
|
"multiplierInvalid": "Multipliers must be a positive number.",
|
||||||
"multiplierDuplicate": "Each role or channel may only appear once."
|
"multiplierDuplicate": "Each role or channel may only appear once.",
|
||||||
|
"rewardMissingRole": "Every reward needs a role.",
|
||||||
|
"rewardInvalidLevel": "Level must be a whole number of at least 1.",
|
||||||
|
"rewardDuplicateLevel": "Each level may only have one reward."
|
||||||
},
|
},
|
||||||
"levelUpTitle": "Level-up message",
|
"levelUpTitle": "Level-up message",
|
||||||
"levelUpMode": "Mode",
|
"levelUpMode": "Mode",
|
||||||
@@ -533,7 +536,13 @@
|
|||||||
},
|
},
|
||||||
"levelUpChannelId": "Level-up channel",
|
"levelUpChannelId": "Level-up channel",
|
||||||
"levelUpMessage": "Message content",
|
"levelUpMessage": "Message content",
|
||||||
|
"rewardsTitle": "Role rewards",
|
||||||
|
"rewardsDescription": "Choose which role is granted at which level.",
|
||||||
|
"rewardLevel": "Level",
|
||||||
|
"rewardRole": "Role",
|
||||||
|
"addReward": "Add reward",
|
||||||
"stackRewards": "Stack role rewards",
|
"stackRewards": "Stack role rewards",
|
||||||
|
"stackRewardsHint": "On = keep all unlocked reward roles. Off = only the role for the current level.",
|
||||||
"rankCardTitle": "Rank card",
|
"rankCardTitle": "Rank card",
|
||||||
"cardAccentColor": "Accent color",
|
"cardAccentColor": "Accent color",
|
||||||
"cardBackgroundColor": "Background color"
|
"cardBackgroundColor": "Background color"
|
||||||
|
|||||||
@@ -244,6 +244,20 @@
|
|||||||
- Kein Browser-Fingerprinting (DSGVO); IP nur gehasht mit Salt
|
- Kein Browser-Fingerprinting (DSGVO); IP nur gehasht mit Salt
|
||||||
- Keine manuelle „Alt freigeben“-UI (nur Flag in `VerificationRecord`)
|
- Keine manuelle „Alt freigeben“-UI (nur Flag in `VerificationRecord`)
|
||||||
|
|
||||||
|
## Post-Phase – Leveling Rollen-Belohnungen UI (Status: implementiert)
|
||||||
|
|
||||||
|
### Abgeschlossen (Code)
|
||||||
|
|
||||||
|
- Dashboard Leveling: Level→Rolle-Belohnungen bearbeiten (Liste, speichern ersetzt `LevelReward`)
|
||||||
|
- Shared Schema `rewards` + `hasUniqueRewardLevels`; i18n DE/EN
|
||||||
|
- Stapel-Toggle mit Hinweis neben dem Belohnungs-Editor
|
||||||
|
|
||||||
|
### Manuell testen
|
||||||
|
|
||||||
|
- [ ] Belohnung hinzufügen (z. B. Level 5 → Rolle), speichern, Mitglied levelt → Rolle vergeben
|
||||||
|
- [ ] Stack an/aus: stapeln vs. nur aktuelle Belohnungsrolle
|
||||||
|
- [ ] Doppeltes Level → Fehlermeldung, speichern blockiert
|
||||||
|
|
||||||
## Nächster geplanter Schritt
|
## Nächster geplanter Schritt
|
||||||
|
|
||||||
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
|
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
|
||||||
|
|||||||
@@ -269,6 +269,12 @@ export type VerificationConfigDashboardPatch = z.infer<typeof VerificationConfig
|
|||||||
|
|
||||||
export const LevelUpModeDashboardSchema = z.enum(['CHANNEL', 'DM', 'OFF']);
|
export const LevelUpModeDashboardSchema = z.enum(['CHANNEL', 'DM', 'OFF']);
|
||||||
|
|
||||||
|
export const LevelRewardDashboardSchema = z.object({
|
||||||
|
level: z.number().int().min(1).max(10_000),
|
||||||
|
roleId: SnowflakeSchema
|
||||||
|
});
|
||||||
|
export type LevelRewardDashboard = z.infer<typeof LevelRewardDashboardSchema>;
|
||||||
|
|
||||||
export const LevelingConfigDashboardSchema = z.object({
|
export const LevelingConfigDashboardSchema = z.object({
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
textXpMin: z.number().int().positive(),
|
textXpMin: z.number().int().positive(),
|
||||||
@@ -283,6 +289,7 @@ export const LevelingConfigDashboardSchema = z.object({
|
|||||||
levelUpChannelId: OptionalSnowflakeSchema,
|
levelUpChannelId: OptionalSnowflakeSchema,
|
||||||
levelUpMessage: z.string().max(500).nullable().optional(),
|
levelUpMessage: z.string().max(500).nullable().optional(),
|
||||||
stackRewards: z.boolean(),
|
stackRewards: z.boolean(),
|
||||||
|
rewards: z.array(LevelRewardDashboardSchema).max(100),
|
||||||
cardAccentColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
cardAccentColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
|
||||||
cardBackgroundColor: z.string().regex(/^#[0-9a-fA-F]{6}$/)
|
cardBackgroundColor: z.string().regex(/^#[0-9a-fA-F]{6}$/)
|
||||||
});
|
});
|
||||||
@@ -291,6 +298,18 @@ export type LevelingConfigDashboard = z.infer<typeof LevelingConfigDashboardSche
|
|||||||
export const LevelingConfigDashboardPatchSchema = nonEmptyPatch(LevelingConfigDashboardSchema);
|
export const LevelingConfigDashboardPatchSchema = nonEmptyPatch(LevelingConfigDashboardSchema);
|
||||||
export type LevelingConfigDashboardPatch = z.infer<typeof LevelingConfigDashboardPatchSchema>;
|
export type LevelingConfigDashboardPatch = z.infer<typeof LevelingConfigDashboardPatchSchema>;
|
||||||
|
|
||||||
|
/** Returns true when every reward level is unique. */
|
||||||
|
export function hasUniqueRewardLevels(rewards: LevelRewardDashboard[]): boolean {
|
||||||
|
const levels = new Set<number>();
|
||||||
|
for (const reward of rewards) {
|
||||||
|
if (levels.has(reward.level)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
levels.add(reward.level);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Economy
|
// Economy
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user