Enhance component message handling and introduce new features

- Added `ComponentMessageBinding` model to manage message components in the database.
- Updated `WelcomeConfig`, `Tag`, and `ScheduledMessage` models to support new component fields.
- Integrated component handling in various modules, including Scheduler and Tags, to improve message customization.
- Enhanced user experience by implementing new commands for creating and managing component messages in the utility module.
- Updated localization files to reflect new component-related features and improve user guidance.
This commit is contained in:
TheOnlyMace
2026-07-22 22:55:37 +02:00
parent 95eed45ec4
commit 4e135bcf43
46 changed files with 4505 additions and 194 deletions

View File

@@ -0,0 +1,147 @@
'use client';
import {
componentsV2HasContent,
embedHasContent,
type DashboardMessageMode,
type MessageComponentsV2,
type WelcomeEmbed
} from '@nexumi/shared';
import { Send } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
import {
DiscordComponentsV2Builder,
emptyComponentsV2,
normalizeComponentsV2
} from '@/components/ui/discord-components-v2-builder';
import { DiscordEmbedBuilder, emptyEmbed, normalizeEmbed } from '@/components/ui/discord-embed-builder';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
interface MessagesManagerProps {
guildId: string;
}
export function MessagesManager({ guildId }: MessagesManagerProps) {
const t = useTranslations();
const [channelId, setChannelId] = useState('');
const [mode, setMode] = useState<DashboardMessageMode>('EMBED');
const [embed, setEmbed] = useState<WelcomeEmbed | null>(emptyEmbed());
const [components, setComponents] = useState<MessageComponentsV2 | null>(emptyComponentsV2());
const [sending, setSending] = useState(false);
async function handleSend() {
if (!channelId.trim()) {
toast.error(t('modulePages.messages.channelRequired'));
return;
}
const normalizedEmbed = mode === 'EMBED' ? normalizeEmbed(embed) : null;
const normalizedComponents = mode === 'COMPONENTS_V2' ? normalizeComponentsV2(components) : null;
if (
mode === 'EMBED' &&
!embedHasContent(normalizedEmbed) &&
normalizedEmbed?.color === undefined
) {
toast.error(t('modulePages.messages.embedRequired'));
return;
}
if (mode === 'COMPONENTS_V2' && !componentsV2HasContent(normalizedComponents)) {
toast.error(t('modulePages.messages.componentsRequired'));
return;
}
setSending(true);
try {
const response = await fetch(`/api/guilds/${guildId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channelId: channelId.trim(),
mode,
embed: normalizedEmbed,
components: normalizedComponents
})
});
const body = (await response.json().catch(() => null)) as { error?: string; messageId?: string | null } | null;
if (!response.ok || !body) {
toast.error(body?.error ?? t('common.saveError'));
return;
}
toast.success(
body.messageId
? t('modulePages.messages.sentWithId', { id: body.messageId })
: t('modulePages.messages.sent')
);
} catch {
toast.error(t('common.saveError'));
} finally {
setSending(false);
}
}
return (
<FieldAnchor id="field-messages-send">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.messages.sendTitle')}</CardTitle>
<CardDescription>{t('modulePages.messages.sendDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>{t('modulePages.messages.channelId')}</Label>
<DiscordChannelSelect guildId={guildId} value={channelId} onChange={setChannelId} />
</div>
<div className="space-y-2">
<Label>{t('modulePages.messages.mode')}</Label>
<div className="flex flex-wrap gap-2">
{(['EMBED', 'COMPONENTS_V2'] as DashboardMessageMode[]).map((entry) => (
<Button
key={entry}
type="button"
variant={mode === entry ? 'default' : 'outline'}
size="sm"
className={cn(mode !== entry && 'text-muted-foreground')}
onClick={() => setMode(entry)}
>
{t(`modulePages.messages.modes.${entry}`)}
</Button>
))}
</div>
</div>
{mode === 'EMBED' ? (
<DiscordEmbedBuilder
value={embed}
onChange={setEmbed}
placeholderPreset="scheduler"
showClearHint={false}
/>
) : (
<DiscordComponentsV2Builder
guildId={guildId}
value={components}
onChange={setComponents}
placeholderPreset="scheduler"
showClearHint={false}
/>
)}
<Button type="button" onClick={handleSend} disabled={sending}>
<Send className="size-4" />
{sending ? t('modulePages.messages.sending') : t('modulePages.messages.send')}
</Button>
</CardContent>
</Card>
</FieldAnchor>
);
}

View File

@@ -1,6 +1,12 @@
'use client';
import { embedHasContent, type ScheduledMessageDashboard, type WelcomeEmbed } from '@nexumi/shared';
import {
componentsV2HasContent,
embedHasContent,
type MessageComponentsV2,
type ScheduledMessageDashboard,
type WelcomeEmbed
} from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
@@ -9,17 +15,27 @@ import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
import {
DiscordComponentsV2Builder,
emptyComponentsV2,
normalizeComponentsV2
} from '@/components/ui/discord-components-v2-builder';
import { DiscordEmbedBuilder, normalizeEmbed } from '@/components/ui/discord-embed-builder';
import { DiscordRoleSelect } from '@/components/ui/discord-role-select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PlaceholderHelp } from '@/components/ui/placeholder-help';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
type ScheduleContentMode = 'text' | 'embed' | 'components_v2';
const EMPTY_DRAFT = {
channelId: '',
contentMode: 'text' as ScheduleContentMode,
content: '',
embed: null as WelcomeEmbed | null,
components: null as MessageComponentsV2 | null,
rolePingId: '',
cron: '',
runAt: ''
@@ -30,7 +46,7 @@ function formatTiming(schedule: ScheduledMessageDashboard, t: (key: string) => s
return `${t('modulePages.scheduler.cron')}: ${schedule.cron}`;
}
if (schedule.runAt) {
return `${t('modulePages.scheduler.runAt')}: ${new Date(schedule.runAt).toLocaleString(undefined, {
return `${new Date(schedule.runAt).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
})}`;
@@ -42,6 +58,9 @@ function schedulePreview(schedule: ScheduledMessageDashboard, t: (key: string) =
if (schedule.content?.trim()) {
return schedule.content;
}
if (componentsV2HasContent(schedule.components)) {
return t('modulePages.scheduler.componentsPreview');
}
if (schedule.embed?.title?.trim()) {
return schedule.embed.title;
}
@@ -69,13 +88,21 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
const [creating, setCreating] = useState(false);
async function handleCreate() {
const embed = normalizeEmbed(draft.embed);
const embed = draft.contentMode === 'embed' ? normalizeEmbed(draft.embed) : null;
const components =
draft.contentMode === 'components_v2' ? normalizeComponentsV2(draft.components ?? emptyComponentsV2()) : null;
const content = draft.contentMode === 'text' ? draft.content.trim() || null : null;
const hasContent =
Boolean(draft.content.trim()) || embedHasContent(embed) || embed?.color !== undefined;
Boolean(content) ||
(draft.contentMode === 'embed' && (embedHasContent(embed) || embed?.color !== undefined)) ||
(draft.contentMode === 'components_v2' && componentsV2HasContent(components));
if (!draft.channelId.trim() || !hasContent || (!draft.cron.trim() && !draft.runAt)) {
toast.error(t('modulePages.scheduler.formIncomplete'));
return;
}
setCreating(true);
try {
const response = await fetch(`/api/guilds/${guildId}/scheduler`, {
@@ -83,8 +110,9 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channelId: draft.channelId.trim(),
content: draft.content.trim() || null,
content,
embed,
components,
rolePingId: draft.rolePingId.trim() || undefined,
cron: draft.cron.trim() || null,
runAt: draft.cron.trim() ? null : draft.runAt ? new Date(draft.runAt).toISOString() : null
@@ -169,23 +197,60 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
/>
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.scheduler.content')}</Label>
<Textarea
value={draft.content}
onChange={(event) => setDraft((prev) => ({ ...prev, content: event.target.value }))}
rows={3}
/>
<PlaceholderHelp preset="scheduler" />
</div>
<div className="space-y-2">
<Label>{t('modulePages.scheduler.embed')}</Label>
<DiscordEmbedBuilder
value={draft.embed}
onChange={(embed) => setDraft((prev) => ({ ...prev, embed }))}
placeholderPreset="scheduler"
/>
<Label>{t('modulePages.scheduler.contentMode')}</Label>
<div className="flex flex-wrap gap-2">
{(['text', 'embed', 'components_v2'] as ScheduleContentMode[]).map((mode) => (
<Button
key={mode}
type="button"
variant={draft.contentMode === mode ? 'default' : 'outline'}
size="sm"
className={cn(draft.contentMode !== mode && 'text-muted-foreground')}
onClick={() => setDraft((prev) => ({ ...prev, contentMode: mode }))}
>
{t(`modulePages.scheduler.contentModes.${mode}`)}
</Button>
))}
</div>
</div>
{draft.contentMode === 'text' ? (
<div className="space-y-2">
<Label>{t('modulePages.scheduler.content')}</Label>
<Textarea
value={draft.content}
onChange={(event) => setDraft((prev) => ({ ...prev, content: event.target.value }))}
rows={3}
/>
<PlaceholderHelp preset="scheduler" />
</div>
) : null}
{draft.contentMode === 'embed' ? (
<div className="space-y-2">
<Label>{t('modulePages.scheduler.embed')}</Label>
<DiscordEmbedBuilder
value={draft.embed}
onChange={(embed) => setDraft((prev) => ({ ...prev, embed }))}
placeholderPreset="scheduler"
/>
</div>
) : null}
{draft.contentMode === 'components_v2' ? (
<div className="space-y-2">
<Label>{t('modulePages.scheduler.components')}</Label>
<DiscordComponentsV2Builder
guildId={guildId}
value={draft.components ?? emptyComponentsV2()}
onChange={(components) => setDraft((prev) => ({ ...prev, components }))}
placeholderPreset="scheduler"
/>
</div>
) : null}
<p className="text-xs text-muted-foreground">{t('modulePages.scheduler.cronHint')}</p>
<Button type="button" onClick={handleCreate} disabled={creating}>
<Plus className="size-4" />

View File

@@ -1,6 +1,13 @@
'use client';
import { embedHasContent, type TagDashboard, type TagResponseType, type WelcomeEmbed } from '@nexumi/shared';
import {
componentsV2HasContent,
embedHasContent,
type MessageComponentsV2,
type TagDashboard,
type TagResponseType,
type WelcomeEmbed
} from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
@@ -9,6 +16,10 @@ import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiscordChannelMultiSelect } from '@/components/ui/discord-channel-select';
import {
DiscordComponentsV2Builder,
normalizeComponentsV2
} from '@/components/ui/discord-components-v2-builder';
import { DiscordEmbedBuilder, normalizeEmbed } from '@/components/ui/discord-embed-builder';
import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
import { Input } from '@/components/ui/input';
@@ -17,7 +28,7 @@ import { PlaceholderHelp } from '@/components/ui/placeholder-help';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
const RESPONSE_TYPES: TagResponseType[] = ['TEXT', 'EMBED'];
const RESPONSE_TYPES: TagResponseType[] = ['TEXT', 'EMBED', 'COMPONENTS_V2'];
interface LocalTag extends TagDashboard {
clientKey: string;
@@ -28,6 +39,7 @@ const EMPTY_DRAFT = {
name: '',
content: '',
embed: null as WelcomeEmbed | null,
components: null as MessageComponentsV2 | null,
responseType: 'TEXT' as TagResponseType,
triggerWord: '',
allowedRoleIds: [] as string[],
@@ -45,20 +57,35 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
initialTags.map((tag, index) => ({
...tag,
embed: tag.embed ?? null,
components: tag.components ?? null,
clientKey: tag.id ?? `existing-${index}`
}))
);
const [draft, setDraft] = useState(EMPTY_DRAFT);
const [creating, setCreating] = useState(false);
function validateTagPayload(responseType: TagResponseType, embed: WelcomeEmbed | null, components: MessageComponentsV2 | null) {
const normalizedEmbed = responseType === 'EMBED' ? normalizeEmbed(embed) : null;
const normalizedComponents = responseType === 'COMPONENTS_V2' ? normalizeComponentsV2(components) : null;
if (responseType === 'EMBED' && !embedHasContent(normalizedEmbed) && normalizedEmbed?.color === undefined) {
toast.error(t('modulePages.tags.embedRequired'));
return null;
}
if (responseType === 'COMPONENTS_V2' && !componentsV2HasContent(normalizedComponents)) {
toast.error(t('modulePages.tags.componentsRequired'));
return null;
}
return { embed: normalizedEmbed, components: normalizedComponents };
}
async function handleCreate() {
if (!draft.name.trim()) {
toast.error(t('modulePages.tags.nameRequired'));
return;
}
const embed = draft.responseType === 'EMBED' ? normalizeEmbed(draft.embed) : null;
if (draft.responseType === 'EMBED' && !embedHasContent(embed) && embed?.color === undefined) {
toast.error(t('modulePages.tags.embedRequired'));
const validated = validateTagPayload(draft.responseType, draft.embed, draft.components);
if (!validated) {
return;
}
setCreating(true);
@@ -68,8 +95,9 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: draft.name.trim(),
content: draft.content.trim() || null,
embed,
content: draft.responseType === 'TEXT' ? draft.content.trim() || null : null,
embed: validated.embed,
components: validated.components,
responseType: draft.responseType,
triggerWord: draft.triggerWord.trim() || null,
allowedRoleIds: draft.allowedRoleIds,
@@ -98,9 +126,8 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
}
async function handleSave(tag: LocalTag) {
const embed = tag.responseType === 'EMBED' ? normalizeEmbed(tag.embed ?? null) : null;
if (tag.responseType === 'EMBED' && !embedHasContent(embed) && embed?.color === undefined) {
toast.error(t('modulePages.tags.embedRequired'));
const validated = validateTagPayload(tag.responseType, tag.embed ?? null, tag.components ?? null);
if (!validated) {
return;
}
setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, saving: true } : entry)));
@@ -110,8 +137,9 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: tag.name,
content: tag.content,
embed,
content: tag.responseType === 'TEXT' ? tag.content : null,
embed: validated.embed,
components: validated.components,
responseType: tag.responseType,
triggerWord: tag.triggerWord,
allowedRoleIds: tag.allowedRoleIds,
@@ -155,6 +183,45 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
}
}
function renderResponseEditor(
responseType: TagResponseType,
embed: WelcomeEmbed | null,
components: MessageComponentsV2 | null,
content: string,
onEmbedChange: (embed: WelcomeEmbed | null) => void,
onComponentsChange: (components: MessageComponentsV2 | null) => void,
onContentChange: (content: string) => void
) {
if (responseType === 'TEXT') {
return (
<div className="space-y-2">
<Label>{t('modulePages.tags.content')}</Label>
<Textarea rows={3} value={content} onChange={(event) => onContentChange(event.target.value)} />
<PlaceholderHelp preset="tags" />
</div>
);
}
if (responseType === 'EMBED') {
return (
<div className="space-y-2">
<Label>{t('modulePages.tags.embed')}</Label>
<DiscordEmbedBuilder value={embed} onChange={onEmbedChange} placeholderPreset="tags" />
</div>
);
}
return (
<div className="space-y-2">
<Label>{t('modulePages.tags.components')}</Label>
<DiscordComponentsV2Builder
guildId={guildId}
value={components}
onChange={onComponentsChange}
placeholderPreset="tags"
/>
</div>
);
}
return (
<div className="space-y-6">
<FieldAnchor id="field-tags-create">
@@ -189,21 +256,14 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
<Input value={draft.triggerWord} onChange={(event) => setDraft((prev) => ({ ...prev, triggerWord: event.target.value }))} placeholder={t('common.optional')} />
</div>
</div>
{draft.responseType === 'TEXT' ? (
<div className="space-y-2">
<Label>{t('modulePages.tags.content')}</Label>
<Textarea rows={3} value={draft.content} onChange={(event) => setDraft((prev) => ({ ...prev, content: event.target.value }))} />
<PlaceholderHelp preset="tags" />
</div>
) : (
<div className="space-y-2">
<Label>{t('modulePages.tags.embed')}</Label>
<DiscordEmbedBuilder
value={draft.embed}
onChange={(embed) => setDraft((prev) => ({ ...prev, embed }))}
placeholderPreset="tags"
/>
</div>
{renderResponseEditor(
draft.responseType,
draft.embed,
draft.components,
draft.content,
(embed) => setDraft((prev) => ({ ...prev, embed })),
(components) => setDraft((prev) => ({ ...prev, components })),
(content) => setDraft((prev) => ({ ...prev, content }))
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
@@ -266,25 +326,23 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
<Input value={tag.triggerWord ?? ''} onChange={(event) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, triggerWord: event.target.value || null } : entry)))} />
</div>
</div>
{tag.responseType === 'TEXT' ? (
<div className="space-y-2">
<Label>{t('modulePages.tags.content')}</Label>
<Textarea rows={3} value={tag.content ?? ''} onChange={(event) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, content: event.target.value } : entry)))} />
<PlaceholderHelp preset="tags" />
</div>
) : (
<div className="space-y-2">
<Label>{t('modulePages.tags.embed')}</Label>
<DiscordEmbedBuilder
value={tag.embed ?? null}
onChange={(embed) =>
setTags((prev) =>
prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, embed } : entry))
)
}
placeholderPreset="tags"
/>
</div>
{renderResponseEditor(
tag.responseType,
tag.embed ?? null,
tag.components ?? null,
tag.content ?? '',
(embed) =>
setTags((prev) =>
prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, embed } : entry))
),
(components) =>
setTags((prev) =>
prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, components } : entry))
),
(content) =>
setTags((prev) =>
prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, content } : entry))
)
)}
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">

View File

@@ -1,10 +1,15 @@
'use client';
import type { WelcomeConfigDashboard, WelcomeEmbed } from '@nexumi/shared';
import type { MessageComponentsV2, WelcomeConfigDashboard, WelcomeEmbed } from '@nexumi/shared';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
import {
DiscordComponentsV2Builder,
componentsV2FromUnknown,
normalizeComponentsV2
} from '@/components/ui/discord-components-v2-builder';
import {
DiscordEmbedBuilder,
embedFromUnknown,
@@ -31,16 +36,24 @@ const WELCOME_PREVIEW_VARS = {
memberCount: '128'
};
interface LocalWelcomeValue extends Omit<WelcomeConfigDashboard, 'welcomeEmbed' | 'leaveEmbed'> {
interface LocalWelcomeValue extends Omit<
WelcomeConfigDashboard,
'welcomeEmbed' | 'leaveEmbed' | 'welcomeComponents' | 'leaveComponents'
> {
welcomeEmbed: WelcomeEmbed | null;
leaveEmbed: WelcomeEmbed | null;
welcomeComponents: MessageComponentsV2 | null;
leaveComponents: MessageComponentsV2 | null;
}
function toLocal(config: WelcomeConfigDashboard): LocalWelcomeValue {
return {
...config,
welcomeEmbed: embedFromUnknown(config.welcomeEmbed),
leaveEmbed: embedFromUnknown(config.leaveEmbed)
leaveEmbed: embedFromUnknown(config.leaveEmbed),
welcomeComponents: componentsV2FromUnknown(config.welcomeComponents),
leaveComponents: componentsV2FromUnknown(config.leaveComponents),
leaveType: config.leaveType ?? 'TEXT'
};
}
@@ -48,7 +61,9 @@ async function saveWelcome(guildId: string, value: LocalWelcomeValue): Promise<S
const payload: WelcomeConfigDashboard = {
...value,
welcomeEmbed: normalizeEmbed(value.welcomeEmbed),
leaveEmbed: normalizeEmbed(value.leaveEmbed)
leaveEmbed: normalizeEmbed(value.leaveEmbed),
welcomeComponents: normalizeComponentsV2(value.welcomeComponents),
leaveComponents: normalizeComponentsV2(value.leaveComponents)
};
try {
@@ -121,33 +136,52 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
<SelectItem value="TEXT">{t('modulePages.welcome.type.TEXT')}</SelectItem>
<SelectItem value="EMBED">{t('modulePages.welcome.type.EMBED')}</SelectItem>
<SelectItem value="IMAGE">{t('modulePages.welcome.type.IMAGE')}</SelectItem>
<SelectItem value="COMPONENTS_V2">{t('modulePages.welcome.type.COMPONENTS_V2')}</SelectItem>
</SelectContent>
</Select>
</div>
</FieldAnchor>
</div>
<FieldAnchor id="field-welcome-content">
<div className="space-y-2">
<Label>{t('modulePages.welcome.welcomeContent')}</Label>
<Textarea
value={value.welcomeContent ?? ''}
onChange={(event) => setValue((prev) => ({ ...prev, welcomeContent: event.target.value || null }))}
placeholder={t('modulePages.welcome.contentPlaceholder')}
/>
<PlaceholderHelp preset="welcome" />
</div>
</FieldAnchor>
<FieldAnchor id="field-welcome-embed">
<div className="space-y-2">
<Label>{t('modulePages.welcome.welcomeEmbed')}</Label>
<DiscordEmbedBuilder
value={value.welcomeEmbed}
onChange={(welcomeEmbed) => setValue((prev) => ({ ...prev, welcomeEmbed }))}
previewVars={WELCOME_PREVIEW_VARS}
placeholderPreset="welcome"
/>
</div>
</FieldAnchor>
{value.welcomeType === 'TEXT' ? (
<FieldAnchor id="field-welcome-content">
<div className="space-y-2">
<Label>{t('modulePages.welcome.welcomeContent')}</Label>
<Textarea
value={value.welcomeContent ?? ''}
onChange={(event) => setValue((prev) => ({ ...prev, welcomeContent: event.target.value || null }))}
placeholder={t('modulePages.welcome.contentPlaceholder')}
/>
<PlaceholderHelp preset="welcome" />
</div>
</FieldAnchor>
) : null}
{value.welcomeType === 'EMBED' ? (
<FieldAnchor id="field-welcome-embed">
<div className="space-y-2">
<Label>{t('modulePages.welcome.welcomeEmbed')}</Label>
<DiscordEmbedBuilder
value={value.welcomeEmbed}
onChange={(welcomeEmbed) => setValue((prev) => ({ ...prev, welcomeEmbed }))}
previewVars={WELCOME_PREVIEW_VARS}
placeholderPreset="welcome"
/>
</div>
</FieldAnchor>
) : null}
{value.welcomeType === 'COMPONENTS_V2' ? (
<FieldAnchor id="field-welcome-components">
<div className="space-y-2">
<Label>{t('modulePages.welcome.welcomeComponents')}</Label>
<DiscordComponentsV2Builder
guildId={guildId}
value={value.welcomeComponents}
onChange={(welcomeComponents) => setValue((prev) => ({ ...prev, welcomeComponents }))}
previewVars={WELCOME_PREVIEW_VARS}
placeholderPreset="welcome"
/>
</div>
</FieldAnchor>
) : null}
<FieldAnchor id="field-welcome-dm">
<div className="space-y-2">
<div className="flex items-center justify-between rounded-md border border-border p-4">
@@ -185,27 +219,31 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
/>
</div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-2">
<FieldAnchor id="field-welcome-image-title">
<div className="space-y-2">
<Label>{t('modulePages.welcome.imageTitle')}</Label>
<Input
value={value.imageTitle ?? ''}
onChange={(event) => setValue((prev) => ({ ...prev, imageTitle: event.target.value || null }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-welcome-image-subtitle">
<div className="space-y-2">
<Label>{t('modulePages.welcome.imageSubtitle')}</Label>
<Input
value={value.imageSubtitle ?? ''}
onChange={(event) => setValue((prev) => ({ ...prev, imageSubtitle: event.target.value || null }))}
/>
</div>
</FieldAnchor>
</div>
<PlaceholderHelp preset="welcome" />
{value.welcomeType === 'IMAGE' ? (
<>
<div className="grid gap-4 sm:grid-cols-2">
<FieldAnchor id="field-welcome-image-title">
<div className="space-y-2">
<Label>{t('modulePages.welcome.imageTitle')}</Label>
<Input
value={value.imageTitle ?? ''}
onChange={(event) => setValue((prev) => ({ ...prev, imageTitle: event.target.value || null }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-welcome-image-subtitle">
<div className="space-y-2">
<Label>{t('modulePages.welcome.imageSubtitle')}</Label>
<Input
value={value.imageSubtitle ?? ''}
onChange={(event) => setValue((prev) => ({ ...prev, imageSubtitle: event.target.value || null }))}
/>
</div>
</FieldAnchor>
</div>
<PlaceholderHelp preset="welcome" />
</>
) : null}
</CardContent>
</Card>
@@ -234,30 +272,68 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-leave-content">
<FieldAnchor id="field-leave-type">
<div className="space-y-2">
<Label>{t('modulePages.welcome.leaveContent')}</Label>
<Textarea
value={value.leaveContent ?? ''}
onChange={(event) => setValue((prev) => ({ ...prev, leaveContent: event.target.value || null }))}
placeholder={t('modulePages.welcome.leaveContentPlaceholder')}
/>
<PlaceholderHelp preset="welcome" />
</div>
</FieldAnchor>
<FieldAnchor id="field-leave-embed">
<div className="space-y-2">
<Label>{t('modulePages.welcome.leaveEmbed')}</Label>
<DiscordEmbedBuilder
value={value.leaveEmbed}
onChange={(leaveEmbed) => setValue((prev) => ({ ...prev, leaveEmbed }))}
previewVars={WELCOME_PREVIEW_VARS}
placeholderPreset="welcome"
titlePlaceholder={t('modulePages.welcome.leaveEmbedTitlePlaceholder')}
descriptionPlaceholder={t('modulePages.welcome.leaveEmbedDescriptionPlaceholder')}
/>
<Label>{t('modulePages.welcome.leaveType')}</Label>
<Select
value={value.leaveType}
onValueChange={(leaveType) =>
setValue((prev) => ({ ...prev, leaveType: leaveType as WelcomeConfigDashboard['leaveType'] }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="TEXT">{t('modulePages.welcome.leaveTypes.TEXT')}</SelectItem>
<SelectItem value="EMBED">{t('modulePages.welcome.leaveTypes.EMBED')}</SelectItem>
<SelectItem value="COMPONENTS_V2">{t('modulePages.welcome.leaveTypes.COMPONENTS_V2')}</SelectItem>
</SelectContent>
</Select>
</div>
</FieldAnchor>
{value.leaveType === 'TEXT' ? (
<FieldAnchor id="field-leave-content">
<div className="space-y-2">
<Label>{t('modulePages.welcome.leaveContent')}</Label>
<Textarea
value={value.leaveContent ?? ''}
onChange={(event) => setValue((prev) => ({ ...prev, leaveContent: event.target.value || null }))}
placeholder={t('modulePages.welcome.leaveContentPlaceholder')}
/>
<PlaceholderHelp preset="welcome" />
</div>
</FieldAnchor>
) : null}
{value.leaveType === 'EMBED' ? (
<FieldAnchor id="field-leave-embed">
<div className="space-y-2">
<Label>{t('modulePages.welcome.leaveEmbed')}</Label>
<DiscordEmbedBuilder
value={value.leaveEmbed}
onChange={(leaveEmbed) => setValue((prev) => ({ ...prev, leaveEmbed }))}
previewVars={WELCOME_PREVIEW_VARS}
placeholderPreset="welcome"
titlePlaceholder={t('modulePages.welcome.leaveEmbedTitlePlaceholder')}
descriptionPlaceholder={t('modulePages.welcome.leaveEmbedDescriptionPlaceholder')}
/>
</div>
</FieldAnchor>
) : null}
{value.leaveType === 'COMPONENTS_V2' ? (
<FieldAnchor id="field-leave-components">
<div className="space-y-2">
<Label>{t('modulePages.welcome.leaveComponents')}</Label>
<DiscordComponentsV2Builder
guildId={guildId}
value={value.leaveComponents}
onChange={(leaveComponents) => setValue((prev) => ({ ...prev, leaveComponents }))}
previewVars={WELCOME_PREVIEW_VARS}
placeholderPreset="welcome"
/>
</div>
</FieldAnchor>
) : null}
</CardContent>
</Card>
</div>