Settings: Splitting up settings form into many smaller forms like for channels

This commit is contained in:
Karl Ludwig Weise
2026-10-08 21:41:25 +02:00
parent a59f8fdfc9
commit f8d0299490
24 changed files with 1197 additions and 609 deletions
+6 -6
View File
@@ -5348,9 +5348,9 @@
}
},
"node_modules/joi": {
"version": "17.13.7",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.7.tgz",
"integrity": "sha512-MF80Dm5Y2veNy8QWVx9Bj3ui4mo7+VPSPsR1M+oaHXV0Gx6zGX9a2F+OZG3Blby9tOlzU9Rs5FUimlEhbKtfnQ==",
"version": "17.13.8",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.8.tgz",
"integrity": "sha512-iPKOGmiRw1jxf/JOPwxmCcUQAOdF359mdzYiP2DJ+TMX0YK2zjK3D+zYOaGjpumWxOFF/l2xVWjRVK5bGSLdEw==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
@@ -6923,9 +6923,9 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
+60 -10
View File
@@ -367,6 +367,7 @@
"weekdays": "Wochentage"
}
},
"save": "Speichern",
"cancel": "Abbrechen",
"actions": "Aktionen",
"view": "Ansehen",
@@ -733,14 +734,17 @@
"settings": {
"title": "Einstellungen",
"loading": "Lade Einstellungen...",
"form": {
"sections": {
"general": "Allgemein",
"address": "Adresse",
"links": "Links",
"advanced": "Erweitert"
},
"fields": {
"success": "Einstellungen aktualisiert",
"error": "Fehler beim Speichern der Einstellungen",
"overview": {
"title": "Einstellungen"
},
"general": {
"title": "Allgemein",
"description": "Name, Beschreibung und Sprachen",
"languages": {
"title": "Sprachen",
"description": "Definiere Sprachen, die Du unterstützen möchtest und lege eine Standardsprache fest.",
"languages": {
"title": "Sprachen",
"description": "Wähle die Sprachen, die Klienten angezeigt bekommen sollen. Du musst mindestens Eine auswählen.",
@@ -753,7 +757,11 @@
"errors": {
"notInLanguages": "Muss eine der ausgewählten Sprachen sein"
}
},
}
},
"branding": {
"title": "Branding",
"description": "Lege Name, Beschreibung und Logo für deine Organisation fest.",
"longName": {
"title": "Name",
"description": "Der öffentliche Name Deiner Organisation."
@@ -764,7 +772,49 @@
"descriptions": {
"title": "Beschreibung (optional)",
"description": "Kurze Beschreibung deiner Organisation. Wird auf der Startseite angezeigt."
},
}
}
},
"address": {
"title": "Adresse",
"description": "Die vollständige Adresse deiner Organisation für Termine",
"address": {
"title": "Besucheradresse",
"description": "Diese Adresse wird in den Termininformationen für deine Klienten angezeigt."
}
},
"links": {
"title": "Links",
"description": "Webseite- und rechtliche Links",
"website": {
"title": "Webseite",
"description": "Wenn du eine Webseite angibst, wird ein Link auf der Startseite angezeigt."
},
"legal": {
"title": "Rechtliche Links",
"description": "Rechtliche Links werden in der Fußzeile angezeigt, wenn sie gesetzt sind."
}
},
"advanced": {
"title": "Erweitert",
"description": "Compliance und erforderliche Kundendaten",
"compliance": {
"title": "Compliance",
"description": "Stelle sicher, dass deine Organisation den gesetzlichen Anforderungen entspricht. Rechtliche Links können im Abschnitt Links gesetzt werden."
},
"requirements": {
"title": "Bedingungen",
"description": "Gib' an welche Informationen für alle öffentlich gebuchten Termine erforderlich sind."
}
},
"form": {
"sections": {
"general": "Allgemein",
"address": "Adresse",
"links": "Links",
"advanced": "Erweitert"
},
"fields": {
"street": {
"title": "Straße"
},
+61 -10
View File
@@ -375,6 +375,7 @@
"weekdays": "Weekdays"
}
},
"save": "Save",
"cancel": "Cancel",
"actions": "Actions",
"view": "View",
@@ -741,14 +742,17 @@
"settings": {
"title": "Settings",
"loading": "Loading settings...",
"form": {
"sections": {
"general": "General",
"address": "Address",
"links": "Links",
"advanced": "Advanced"
},
"fields": {
"success": "Settings updated",
"error": "Error saving settings",
"overview": {
"title": "Settings"
},
"general": {
"title": "General",
"description": "Name, Description and Language settings",
"languages": {
"title": "Languages",
"description": "Define the languages you want to support and set a default language.",
"languages": {
"title": "Languages",
"description": "Define the languages your customers will see. You need to select at least one.",
@@ -761,7 +765,11 @@
"errors": {
"notInLanguages": "Default language must be one of the selected languages"
}
},
}
},
"branding": {
"title": "Branding",
"description": "Set name, description and logo for your organization.",
"longName": {
"title": "Name",
"description": "Public name of your organization."
@@ -772,7 +780,50 @@
"descriptions": {
"title": "Description (optional)",
"description": "Short description of your organization. Will be shown on the home page."
},
}
}
},
"address": {
"title": "Address",
"description": "Your organizations full address for appointments",
"address": {
"title": "Visitor Address",
"description": "This address will be included in appointment information for your clients."
}
},
"links": {
"title": "Links",
"description": "Website and legal links",
"website": {
"title": "Website",
"description": "If you set this, a link will be shown on the home page."
},
"legal": {
"title": "Legal",
"description": "Legal links are shown in the footer, when set."
}
},
"advanced": {
"title": "Advanced",
"description": "Compliance and required client data",
"compliance": {
"title": "Compliance",
"description": "Ensure your organization complies with legal requirements. Legal links can be set in the Links section."
},
"requirements": {
"title": "Requirements",
"description": "Specify globally required client data for publically booked appointments."
}
},
"form": {
"sections": {
"general": "General",
"address": "Address",
"links": "Links",
"advanced": "Advanced"
},
"fields": {
"street": {
"title": "Street"
},
@@ -21,7 +21,7 @@
},
{
title: m["nav.settings"](),
url: ROUTES.DASHBOARD.SETTINGS,
url: ROUTES.DASHBOARD.SETTINGS.MAIN,
isTenantOnly: true,
icon: SettingsIcon,
roles: ["GLOBAL_ADMIN", "TENANT_ADMIN"],
@@ -6,9 +6,16 @@
let {
title,
description,
hint,
class: className,
children,
}: { title: string; description: string; class?: string; children?: Snippet } = $props();
}: {
title: string;
description: string;
hint?: Snippet;
class?: string;
children?: Snippet;
} = $props();
</script>
<div class={cn("flex flex-col gap-3 md:grid md:grid-cols-3 md:gap-20", className)}>
@@ -21,8 +28,13 @@
{description}
</Text>
{/if}
{#if hint}
<div class="mt-4">
{@render hint?.()}
</div>
{/if}
</div>
<div class="col-span-2">
<div class="col-span-2 mt-2">
{@render children?.()}
</div>
</div>
+7 -1
View File
@@ -25,7 +25,13 @@ export const ROUTES = {
CHANNEL_SLOTS: "/dashboard/channels/[channelId]/slots",
CHANNEL_PUBLIC_BOOKING: "/dashboard/channels/[channelId]/public-booking",
ABSENCES: "/dashboard/absences",
SETTINGS: "/dashboard/settings",
SETTINGS: {
MAIN: "/dashboard/settings",
GENERAL: "/dashboard/settings/general",
ADDRESS: "/dashboard/settings/address",
LINKS: "/dashboard/settings/links",
ADVANCED: "/dashboard/settings/advanced",
},
ACCOUNT: {
MAIN: "/dashboard/account",
GENERAL: "/dashboard/account/general",
+9
View File
@@ -103,6 +103,15 @@ const triggerOnboardingNotifications = (
curState: TenantState,
) => {
if (newCurrentTenant && newCurrentTenant?.setupState !== "READY") {
// In the progress on configuring settings?
if (
newCurrentTenant?.setupState === "SETTINGS" &&
curState.currentTenant?.setupState === "SETTINGS"
) {
return;
}
// In the progress on configuring the first channel?
if (newCurrentTenant?.setupState === "CHANNELS") {
const hasCompleteChannel = get(channels).channels.some(
(c) => c.agentIds?.length > 0 && c.slotTemplates?.length > 0 && c.pause === false,
+1 -1
View File
@@ -107,7 +107,7 @@
{
label: m["dashboard.onboarding.sections.settings.action"](),
onClick: () => {
goto(resolve(ROUTES.DASHBOARD.SETTINGS));
goto(resolve(ROUTES.DASHBOARD.SETTINGS.MAIN));
},
},
],
@@ -4,11 +4,10 @@
import { MaxPageWidth } from "$lib/components/layouts/max-page-width";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
import { PageHeadline } from "$lib/components/templates/page-headline";
import { GroupItem } from "$lib/components/ui/group-item";
import * as Item from "$lib/components/ui/item";
import { Text } from "$lib/components/ui/typography";
import { ROUTES } from "$lib/const/routes";
import { auth } from "$lib/stores/auth";
import { ChevronRight } from "@lucide/svelte/icons";
</script>
<SidebarLayout
@@ -22,75 +21,27 @@
<MaxPageWidth maxWidth="md" class="flex flex-col gap-6">
<PageHeadline title={m["account.overview.title"]()} />
<Item.Group class="gap-2">
<Item.Root variant="outline">
{#snippet child({ props })}
<a href={resolve(ROUTES.DASHBOARD.ACCOUNT.GENERAL)} {...props}>
<Item.Content>
<Item.Title>
<Text style="md">{m["account.general.title"]()}</Text>
</Item.Title>
<Item.Description>
<Text style="md">{m["account.general.description"]()}</Text>
</Item.Description>
</Item.Content>
<Item.Actions>
<ChevronRight class="size-4" />
</Item.Actions>
</a>
{/snippet}
</Item.Root>
<!-- <Item.Root variant="outline">
{#snippet child({ props })}
<a href={resolve(ROUTES.DASHBOARD.ACCOUNT.CHANGE_EMAIL)} {...props}>
<Item.Content>
<Item.Title>
<Text style="md">{m["account.change-email.title"]()}</Text>
</Item.Title>
<Item.Description>
<Text style="md">{m["account.change-email.description"]()}</Text>
</Item.Description>
</Item.Content>
<Item.Actions>
<ChevronRight class="size-4" />
</Item.Actions>
</a>
{/snippet}
</Item.Root> -->
<Item.Root variant="outline">
{#snippet child({ props })}
<a href={resolve(ROUTES.DASHBOARD.ACCOUNT.PASSKEYS)} {...props}>
<Item.Content>
<Item.Title>
<Text style="md">{m["account.passkeys.title"]()}</Text>
</Item.Title>
<Item.Description>
<Text style="md">{m["account.passkeys.description"]()}</Text>
</Item.Description>
</Item.Content>
<Item.Actions>
<ChevronRight class="size-4" />
</Item.Actions>
</a>
{/snippet}
</Item.Root>
<GroupItem
title={m["account.general.title"]()}
description={m["account.general.description"]()}
href={resolve(ROUTES.DASHBOARD.ACCOUNT.GENERAL)}
/>
<!-- <GroupItem
title={m["account.change-email.title"]()}
description={m["account.change-email.description"]()}
href={resolve(ROUTES.DASHBOARD.ACCOUNT.CHANGE_EMAIL)}
/> -->
<GroupItem
title={m["account.passkeys.title"]()}
description={m["account.passkeys.description"]()}
href={resolve(ROUTES.DASHBOARD.ACCOUNT.PASSKEYS)}
/>
{#if $auth.user?.role === "GLOBAL_ADMIN"}
<Item.Root variant="outline">
{#snippet child({ props })}
<a href={resolve(ROUTES.DASHBOARD.ACCOUNT.CHANGE_PASSPHRASE)} {...props}>
<Item.Content>
<Item.Title>
<Text style="md">{m["account.change-passphrase.title"]()}</Text>
</Item.Title>
<Item.Description>
<Text style="md">{m["account.change-passphrase.description"]()}</Text>
</Item.Description>
</Item.Content>
<Item.Actions>
<ChevronRight class="size-4" />
</Item.Actions>
</a>
{/snippet}
</Item.Root>
<GroupItem
title={m["account.change-passphrase.title"]()}
description={m["account.change-passphrase.description"]()}
href={resolve(ROUTES.DASHBOARD.ACCOUNT.CHANGE_PASSPHRASE)}
/>
{/if}
</Item.Group>
</MaxPageWidth>
@@ -0,0 +1,125 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import { FormSplit } from "$lib/components/templates/form-split";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import { ROUTES } from "$lib/const/routes";
import { auth } from "$lib/stores/auth";
import { tenants } from "$lib/stores/tenants";
import type { TTenantSettings } from "$lib/types/tenant";
import { untrack } from "svelte";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from "./schema";
let { entity }: { entity: TTenantSettings } = $props();
const form = superForm(
untrack(() => ({
id: entity.id,
address: entity.address,
})),
{
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
tenants.reload();
toast.success(m["settings.success"]());
} else if (event.result.type === "failure") {
toast.error(m["settings.error"]());
}
isSubmitting = false;
},
onSubmit: () => (isSubmitting = true),
},
);
let isSubmitting = $state(false);
const { form: formData, enhance } = form;
</script>
<Form.Root
{enhance}
action={`${ROUTES.DASHBOARD.SETTINGS.MAIN}?/edit`}
class="flex flex-col gap-10"
>
<Form.Field {form} name="id" class="hidden">
<Form.Control>
{#snippet children({ props })}
<Input {...props} bind:value={$formData.id} type="hidden" />
{/snippet}
</Form.Control>
</Form.Field>
<FormSplit
title={m["settings.address.address.title"]()}
description={m["settings.address.address.description"]()}
>
<div class="flex flex-col gap-4">
<div class="flex justify-between gap-3">
<Form.Field {form} name="address.street" class="flex-4/5">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.street.title"]()}</Form.Label>
<Input {...props} bind:value={$formData.address.street} type="text" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="address.number" class="flex-1/5">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.number.title"]()}</Form.Label>
<Input {...props} bind:value={$formData.address.number} type="text" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</div>
<Form.Field {form} name="address.additionalAddressInfo">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.additionalAddressInfo.title"]()}</Form.Label>
<Input {...props} bind:value={$formData.address.additionalAddressInfo} type="text" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<div class="flex justify-between gap-3">
<Form.Field {form} name="address.zip" class="flex-2/5">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.zip.title"]()}</Form.Label>
<Input {...props} bind:value={$formData.address.zip} type="text" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="address.city" class="flex-3/5">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.city.title"]()}</Form.Label>
<Input {...props} bind:value={$formData.address.city} type="text" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</div>
</div>
</FormSplit>
<div class="mt-6 flex flex-col gap-4">
<Form.Button
size="lg"
type="submit"
isLoading={isSubmitting}
disabled={isSubmitting}
class="sm:ml-auto"
>
{m["save"]()}
</Form.Button>
</div>
</Form.Root>
@@ -0,0 +1,152 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { m } from "$i18n/messages.js";
import { FormSplit } from "$lib/components/templates/form-split";
import { Button } from "$lib/components/ui/button";
import { CheckboxWithLabel } from "$lib/components/ui/checkbox-with-label";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import * as Select from "$lib/components/ui/select";
import { ROUTES } from "$lib/const/routes";
import { auth } from "$lib/stores/auth";
import { tenants } from "$lib/stores/tenants";
import type { TTenantSettings } from "$lib/types/tenant";
import { ChevronRight } from "@lucide/svelte";
import { untrack } from "svelte";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from "./schema";
let { entity }: { entity: TTenantSettings } = $props();
const autoDeleteDaysOptions = [
{ key: 30, label: m["settings.form.fields.autoDeleteDays.options.30"]() },
{ key: 60, label: m["settings.form.fields.autoDeleteDays.options.60"]() },
{ key: 90, label: m["settings.form.fields.autoDeleteDays.options.90"]() },
{ key: 180, label: m["settings.form.fields.autoDeleteDays.options.180"]() },
{ key: 365, label: m["settings.form.fields.autoDeleteDays.options.365"]() },
];
const form = superForm(
untrack(() => ({
id: entity.id,
settings: {
autoDeleteDays: entity.settings.autoDeleteDays,
requirePhone: entity.settings.requirePhone || false,
},
})),
{
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
tenants.reload();
toast.success(m["settings.success"]());
} else if (event.result.type === "failure") {
toast.error(m["settings.error"]());
}
isSubmitting = false;
},
onSubmit: () => (isSubmitting = true),
},
);
let isSubmitting = $state(false);
const { form: formData, enhance } = form;
</script>
<Form.Root
{enhance}
action={`${ROUTES.DASHBOARD.SETTINGS.MAIN}?/edit`}
class="flex flex-col gap-10"
>
<Form.Field {form} name="id" class="hidden">
<Form.Control>
{#snippet children({ props })}
<Input {...props} bind:value={$formData.id} type="hidden" />
{/snippet}
</Form.Control>
</Form.Field>
<FormSplit
title={m["settings.advanced.compliance.title"]()}
description={m["settings.advanced.compliance.description"]()}
{hint}
>
<Form.Field {form} name="settings.autoDeleteDays">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.autoDeleteDays.title"]()}</Form.Label>
<Select.Root
type="single"
value={$formData.settings.autoDeleteDays.toString()}
name={props.name}
onValueChange={(v) => ($formData.settings.autoDeleteDays = parseInt(v))}
>
<Select.Trigger {...props} class="w-full">
{$formData.settings.autoDeleteDays
? autoDeleteDaysOptions.find((x) => x.key === $formData.settings.autoDeleteDays)
?.label
: m["settings.form.fields.autoDeleteDays.placeholder"]()}
</Select.Trigger>
<Select.Content>
{#each autoDeleteDaysOptions as option (option.key)}
<Select.Item value={option.key.toString()}>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</FormSplit>
<FormSplit
title={m["settings.advanced.requirements.title"]()}
description={m["settings.advanced.requirements.description"]()}
>
<Form.Field {form} name="settings.requirePhone">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.requirePhone.title"]()}</Form.Label>
<CheckboxWithLabel
{...props}
bind:value={$formData.settings.requirePhone}
label={m["settings.form.fields.requirePhone.label"]()}
onCheckedChange={(v) => {
$formData.settings.requirePhone = v;
}}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</FormSplit>
<div class="mt-6 flex flex-col gap-4">
<Form.Button
size="lg"
type="submit"
isLoading={isSubmitting}
disabled={isSubmitting}
class="sm:ml-auto"
>
{m["save"]()}
</Form.Button>
</div>
</Form.Root>
{#snippet hint()}
<Button
variant="link"
size="xs"
href={resolve(ROUTES.DASHBOARD.SETTINGS.LINKS)}
class="text-normal m-0 p-0"
>
<ChevronRight class="size-3 p-0" />
{m["settings.links.legal.title"]()}
</Button>
{/snippet}
@@ -0,0 +1,204 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import { FormSplit } from "$lib/components/templates/form-split";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import { InputCroppedImageBlob } from "$lib/components/ui/input-cropped-image-blob";
import { LanguageTabs } from "$lib/components/ui/language-tabs";
import * as Select from "$lib/components/ui/select";
import { Textarea } from "$lib/components/ui/textarea";
import { supportedLocales, translatedLocales } from "$lib/const/locales";
import { ROUTES } from "$lib/const/routes";
import { auth } from "$lib/stores/auth";
import { tenants } from "$lib/stores/tenants";
import type { TTenantSettings } from "$lib/types/tenant";
import DefaultOrgIcon from "@lucide/svelte/icons/landmark";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from "./schema";
import { untrack } from "svelte";
let { entity }: { entity: TTenantSettings } = $props();
const availableLocales = supportedLocales.map((it) => ({
key: it,
label: translatedLocales[it as keyof typeof translatedLocales],
}));
const form = superForm(
untrack(() => ({
id: entity.id,
languages: entity.languages,
defaultLanguage: entity.defaultLanguage,
longName: entity.longName,
logo: entity.logo,
descriptions: supportedLocales.reduce(
(acc, locale) => {
return { ...acc, [locale]: entity.descriptions[locale] || "" };
},
{} as { [key: string]: string },
),
})),
{
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
tenants.reload();
toast.success(m["settings.success"]());
} else if (event.result.type === "failure") {
toast.error(m["settings.error"]());
}
isSubmitting = false;
},
onSubmit: () => (isSubmitting = true),
},
);
let isSubmitting = $state(false);
const { form: formData, enhance } = form;
</script>
<Form.Root
{enhance}
action={`${ROUTES.DASHBOARD.SETTINGS.MAIN}?/edit`}
class="flex flex-col gap-10"
>
<Form.Field {form} name="id" class="hidden">
<Form.Control>
{#snippet children({ props })}
<Input {...props} bind:value={$formData.id} type="hidden" />
{/snippet}
</Form.Control>
</Form.Field>
<FormSplit
title={m["settings.general.languages.title"]()}
description={m["settings.general.languages.description"]()}
>
<div class="flex flex-col gap-4">
<Form.Field {form} name="languages">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.general.languages.languages.title"]()}</Form.Label>
<Select.Root
type="multiple"
bind:value={$formData.languages}
name={props.name}
onValueChange={(v) => ($formData.languages = v)}
>
<Select.Trigger {...props} class="w-full">
{$formData.languages.length > 0
? $formData.languages
.map((id) => availableLocales.find((x) => x.key === id)?.label)
.join(", ")
: m["settings.general.languages.languages.placeholder"]()}
</Select.Trigger>
<Select.Content>
{#each availableLocales as locale (locale.key)}
<Select.Item value={locale.key}>{locale.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Form.Description>
{m["settings.general.languages.languages.description"]()}
</Form.Description>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="defaultLanguage">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.general.languages.defaultLanguage.title"]()}</Form.Label>
<Select.Root
type="single"
bind:value={$formData.defaultLanguage}
name={props.name}
onValueChange={(v) => ($formData.defaultLanguage = v)}
>
<Select.Trigger {...props} class="w-full">
{$formData.defaultLanguage
? availableLocales.find((x) => x.key === $formData.defaultLanguage)?.label
: m["settings.general.languages.defaultLanguage.placeholder"]()}
</Select.Trigger>
<Select.Content>
{#each $formData.languages as language (language)}
<Select.Item value={language}>
{availableLocales.find((x) => x.key === language)?.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Form.Description>
{m["settings.general.languages.defaultLanguage.description"]()}
</Form.Description>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</div>
</FormSplit>
<FormSplit
title={m["settings.general.branding.title"]()}
description={m["settings.general.branding.description"]()}
>
<div class="flex flex-col gap-4">
<Form.Field {form} name="logo">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.general.branding.logo.title"]()}</Form.Label>
<InputCroppedImageBlob
{...props}
bind:value={$formData.logo}
FallbackIcon={DefaultOrgIcon}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="longName">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.name"]()}</Form.Label>
<Input {...props} bind:value={$formData.longName} type="text" autocomplete="off" />
{/snippet}
</Form.Control>
<Form.Description>
{m["settings.general.branding.longName.description"]()}
</Form.Description>
<Form.FieldErrors />
</Form.Field>
<LanguageTabs languages={$formData.languages}>
{#snippet children({ locale })}
<Form.Field {form} name={`descriptions.${locale}`}>
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.general.branding.descriptions.title"]()}</Form.Label>
<Textarea {...props} bind:value={$formData.descriptions[locale]} maxlength={200} />
{/snippet}
</Form.Control>
<Form.Description
>{m["settings.general.branding.descriptions.description"]()}</Form.Description
>
<Form.FieldErrors />
</Form.Field>
{/snippet}
</LanguageTabs>
</div>
</FormSplit>
<div class="mt-6 flex flex-col gap-4">
<Form.Button
size="lg"
type="submit"
isLoading={isSubmitting}
disabled={isSubmitting}
class="sm:ml-auto"
>
{m["save"]()}
</Form.Button>
</div>
</Form.Root>
@@ -0,0 +1,117 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import { FormSplit } from "$lib/components/templates/form-split";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import { ROUTES } from "$lib/const/routes";
import { auth } from "$lib/stores/auth";
import { tenants } from "$lib/stores/tenants";
import type { TTenantSettings } from "$lib/types/tenant";
import { untrack } from "svelte";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from "./schema";
let { entity }: { entity: TTenantSettings } = $props();
const form = superForm(
untrack(() => ({
id: entity.id,
links: entity.links,
})),
{
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
tenants.reload();
toast.success(m["settings.success"]());
} else if (event.result.type === "failure") {
toast.error(m["settings.error"]());
}
isSubmitting = false;
},
onSubmit: () => (isSubmitting = true),
},
);
let isSubmitting = $state(false);
const { form: formData, enhance } = form;
</script>
<Form.Root
{enhance}
action={`${ROUTES.DASHBOARD.SETTINGS.MAIN}?/edit`}
class="flex flex-col gap-10"
>
<Form.Field {form} name="id" class="hidden">
<Form.Control>
{#snippet children({ props })}
<Input {...props} bind:value={$formData.id} type="hidden" />
{/snippet}
</Form.Control>
</Form.Field>
<FormSplit
title={m["settings.links.website.title"]()}
description={m["settings.links.website.description"]()}
>
<Form.Field {form} name="links.website" class="flex-3/5">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.website.title"]()}</Form.Label>
<Input {...props} bind:value={$formData.links.website} type="text" />
{/snippet}
</Form.Control>
<Form.Description>
{m["settings.form.fields.website.description"]()}
</Form.Description>
<Form.FieldErrors />
</Form.Field>
</FormSplit>
<FormSplit
title={m["settings.links.legal.title"]()}
description={m["settings.links.legal.description"]()}
>
<div class="flex flex-col gap-4">
<Form.Field {form} name="links.imprint" class="flex-3/5">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.imprint.title"]()}</Form.Label>
<Input {...props} bind:value={$formData.links.imprint} type="text" />
{/snippet}
</Form.Control>
<Form.Description>
{m["settings.form.fields.imprint.description"]()}
</Form.Description>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="links.privacyStatement" class="flex-3/5">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.privacyStatement.title"]()}</Form.Label>
<Input {...props} bind:value={$formData.links.privacyStatement} type="text" />
{/snippet}
</Form.Control>
<Form.Description>
{m["settings.form.fields.privacyStatement.description"]()}
</Form.Description>
<Form.FieldErrors />
</Form.Field>
</div>
</FormSplit>
<div class="mt-6 flex flex-col gap-4">
<Form.Button
size="lg"
type="submit"
isLoading={isSubmitting}
disabled={isSubmitting}
class="sm:ml-auto"
>
{m["save"]()}
</Form.Button>
</div>
</Form.Root>
@@ -1,343 +0,0 @@
<script lang="ts">
import { m } from "$i18n/messages.js";
import { FormGrid, FormGridItem } from "$lib/components/templates/form-grid";
import { CheckboxWithLabel } from "$lib/components/ui/checkbox-with-label";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import { InputCroppedImageBlob } from "$lib/components/ui/input-cropped-image-blob";
import { LanguageTabs } from "$lib/components/ui/language-tabs";
import * as Select from "$lib/components/ui/select";
import { Textarea } from "$lib/components/ui/textarea";
import { supportedLocales, translatedLocales } from "$lib/const/locales";
import { auth } from "$lib/stores/auth";
import { tenants } from "$lib/stores/tenants";
import type { TTenantSettings } from "$lib/types/tenant";
import DefaultOrgIcon from "@lucide/svelte/icons/landmark";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from "./schema";
let { entity }: { entity: TTenantSettings } = $props();
const autoDeleteDaysOptions = [
{ key: 30, label: m["settings.form.fields.autoDeleteDays.options.30"]() },
{ key: 60, label: m["settings.form.fields.autoDeleteDays.options.60"]() },
{ key: 90, label: m["settings.form.fields.autoDeleteDays.options.90"]() },
{ key: 180, label: m["settings.form.fields.autoDeleteDays.options.180"]() },
{ key: 365, label: m["settings.form.fields.autoDeleteDays.options.365"]() },
];
const availableLocales = supportedLocales.map((it) => ({
key: it,
label: translatedLocales[it as keyof typeof translatedLocales],
}));
// svelte-ignore state_referenced_locally
const form = superForm(
{
id: entity.id,
languages: entity.languages,
defaultLanguage: entity.defaultLanguage,
shortName: entity.shortName,
longName: entity.longName,
logo: entity.logo,
descriptions: supportedLocales.reduce(
(acc, locale) => {
return { ...acc, [locale]: entity.descriptions[locale] || "" };
},
{} as { [key: string]: string },
),
address: entity.address,
links: entity.links,
settings: {
autoDeleteDays: entity.settings.autoDeleteDays,
requirePhone: entity.settings.requirePhone || false,
},
},
{
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
tenants.reload();
toast.success(m["tenants.edit.success"]());
} else if (event.result.type === "failure") {
toast.error(m["tenants.edit.error"]());
}
isSubmitting = false;
},
onSubmit: () => (isSubmitting = true),
},
);
let isSubmitting = $state(false);
const { form: formData, enhance } = form;
</script>
<Form.Root {enhance} action="?/edit">
<Form.Field {form} name="id" class="hidden">
<Form.Control>
{#snippet children({ props })}
<Input {...props} bind:value={$formData.id} type="hidden" />
{/snippet}
</Form.Control>
</Form.Field>
<FormGrid class="grid-flow-dense">
<FormGridItem
title={m["settings.form.sections.general"]()}
hideTitleOnMobile={true}
class="row-span-3"
>
<Form.Field {form} name="languages">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.languages.title"]()}</Form.Label>
<Select.Root
type="multiple"
bind:value={$formData.languages}
name={props.name}
onValueChange={(v) => ($formData.languages = v)}
>
<Select.Trigger {...props} class="w-full">
{$formData.languages.length > 0
? $formData.languages
.map((id) => availableLocales.find((x) => x.key === id)?.label)
.join(", ")
: m["settings.form.fields.languages.placeholder"]()}
</Select.Trigger>
<Select.Content>
{#each availableLocales as locale (locale.key)}
<Select.Item value={locale.key}>{locale.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Form.Description>
{m["settings.form.fields.languages.description"]()}
</Form.Description>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="defaultLanguage">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.defaultLanguage.title"]()}</Form.Label>
<Select.Root
type="single"
bind:value={$formData.defaultLanguage}
name={props.name}
onValueChange={(v) => ($formData.defaultLanguage = v)}
>
<Select.Trigger {...props} class="w-full">
{$formData.defaultLanguage
? availableLocales.find((x) => x.key === $formData.defaultLanguage)?.label
: m["settings.form.fields.defaultLanguage.placeholder"]()}
</Select.Trigger>
<Select.Content>
{#each $formData.languages as language (language)}
<Select.Item value={language}>
{availableLocales.find((x) => x.key === language)?.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Form.Description>
{m["settings.form.fields.defaultLanguage.description"]()}
</Form.Description>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="longName">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.name"]()}</Form.Label>
<Input {...props} bind:value={$formData.longName} type="text" autocomplete="off" />
{/snippet}
</Form.Control>
<Form.Description>
{m["settings.form.fields.longName.description"]()}
</Form.Description>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="logo">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.logo.title"]()}</Form.Label>
<InputCroppedImageBlob
{...props}
bind:value={$formData.logo}
FallbackIcon={DefaultOrgIcon}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<LanguageTabs languages={$formData.languages}>
{#snippet children({ locale })}
<Form.Field {form} name={`descriptions.${locale}`}>
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.descriptions.title"]()}</Form.Label>
<Textarea {...props} bind:value={$formData.descriptions[locale]} maxlength={200} />
{/snippet}
</Form.Control>
<Form.Description
>{m["settings.form.fields.descriptions.description"]()}</Form.Description
>
<Form.FieldErrors />
</Form.Field>
{/snippet}
</LanguageTabs>
</FormGridItem>
<FormGridItem title={m["settings.form.sections.address"]()}>
<div class="flex justify-between gap-3">
<Form.Field {form} name="address.street" class="flex-4/5">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.street.title"]()}</Form.Label>
<Input {...props} bind:value={$formData.address.street} type="text" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="address.number" class="flex-1/5">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.number.title"]()}</Form.Label>
<Input {...props} bind:value={$formData.address.number} type="text" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</div>
<Form.Field {form} name="address.additionalAddressInfo">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.additionalAddressInfo.title"]()}</Form.Label>
<Input {...props} bind:value={$formData.address.additionalAddressInfo} type="text" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<div class="flex justify-between gap-3">
<Form.Field {form} name="address.zip" class="flex-2/5">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.zip.title"]()}</Form.Label>
<Input {...props} bind:value={$formData.address.zip} type="text" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="address.city" class="flex-3/5">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.city.title"]()}</Form.Label>
<Input {...props} bind:value={$formData.address.city} type="text" />
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</div>
</FormGridItem>
<FormGridItem title={m["settings.form.sections.links"]()}>
<Form.Field {form} name="links.website" class="flex-3/5">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.website.title"]()}</Form.Label>
<Input {...props} bind:value={$formData.links.website} type="text" />
{/snippet}
</Form.Control>
<Form.Description>
{m["settings.form.fields.website.description"]()}
</Form.Description>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="links.imprint" class="flex-3/5">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.imprint.title"]()}</Form.Label>
<Input {...props} bind:value={$formData.links.imprint} type="text" />
{/snippet}
</Form.Control>
<Form.Description>
{m["settings.form.fields.imprint.description"]()}
</Form.Description>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="links.privacyStatement" class="flex-3/5">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.privacyStatement.title"]()}</Form.Label>
<Input {...props} bind:value={$formData.links.privacyStatement} type="text" />
{/snippet}
</Form.Control>
<Form.Description>
{m["settings.form.fields.privacyStatement.description"]()}
</Form.Description>
<Form.FieldErrors />
</Form.Field>
</FormGridItem>
<FormGridItem title={m["settings.form.sections.advanced"]()} class="xl:col-start-3">
<Form.Field {form} name="settings.autoDeleteDays">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.autoDeleteDays.title"]()}</Form.Label>
<Select.Root
type="single"
value={$formData.settings.autoDeleteDays.toString()}
name={props.name}
onValueChange={(v) => ($formData.settings.autoDeleteDays = parseInt(v))}
>
<Select.Trigger {...props} class="w-full">
{$formData.settings.autoDeleteDays
? autoDeleteDaysOptions.find((x) => x.key === $formData.settings.autoDeleteDays)
?.label
: m["settings.form.fields.autoDeleteDays.placeholder"]()}
</Select.Trigger>
<Select.Content>
{#each autoDeleteDaysOptions as option (option.key)}
<Select.Item value={option.key.toString()}>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="settings.requirePhone">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["settings.form.fields.requirePhone.title"]()}</Form.Label>
<CheckboxWithLabel
{...props}
bind:value={$formData.settings.requirePhone}
label={m["settings.form.fields.requirePhone.label"]()}
onCheckedChange={(v) => {
$formData.settings.requirePhone = v;
}}
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
</Form.Field>
</FormGridItem>
</FormGrid>
<div class="mt-6 flex flex-col gap-4">
<Form.Button
size="lg"
type="submit"
isLoading={isSubmitting}
disabled={isSubmitting}
class="sm:ml-auto"
>
{m["settings.form.action"]()}
</Form.Button>
</div>
</Form.Root>
@@ -1,5 +1,13 @@
import EditSettingsForm from "./edit-settings-form.svelte";
import EditSettingsFormGeneral from "./edit-settings-form-general.svelte";
import EditSettingsFormAddress from "./edit-settings-form-address.svelte";
import EditSettingsFormAdvanced from "./edit-settings-form-advanced.svelte";
import EditSettingsFormLinks from "./edit-settings-form-links.svelte";
export { EditSettingsForm };
export {
EditSettingsFormGeneral,
EditSettingsFormAddress,
EditSettingsFormAdvanced,
EditSettingsFormLinks,
};
export { formSchema } from "./schema";
export type { FormSchema } from "./schema";
@@ -22,34 +22,39 @@ const optionalUrl = (errorMessage: string) =>
export const formSchema = z
.object({
id: z.string(),
languages: z.array(z.string()).min(1, { message: m["form.errors.languages"]() }),
defaultLanguage: z.string().min(1, { message: m["form.errors.language"]() }),
shortName: z.string(),
longName: z.string().min(1, { message: m["form.errors.longname"]() }),
logo: z.string().optional().or(z.literal("")),
descriptions: z.record(z.string(), z.string()).optional(),
address: z.object({
street: z.string().min(1, { message: m["form.errors.street"]() }),
number: z.string().min(1, { message: m["form.errors.houseNo"]() }),
additionalAddressInfo: z.string().optional().or(z.literal("")),
zip: z.string().min(4, { message: m["form.errors.zip"]() }),
city: z.string().min(1, { message: m["form.errors.city"]() }),
}),
links: z.object({
website: optionalUrl(m["form.errors.url"]()),
imprint: optionalUrl(m["form.errors.url"]()),
privacyStatement: optionalUrl(m["form.errors.url"]()),
}),
settings: z.object({
autoDeleteDays: z.number().min(30, { message: m["form.errors.deleteAfterDays"]() }),
requirePhone: z.boolean().optional().default(false),
}),
languages: z.array(z.string()).min(1, { message: m["form.errors.languages"]() }).optional(),
defaultLanguage: z.string().min(1, { message: m["form.errors.language"]() }).optional(),
longName: z.string().min(1, { message: m["form.errors.longname"]() }).optional(),
logo: z.string().optional().or(z.literal("")).optional(),
descriptions: z.record(z.string(), z.string()).optional().optional(),
address: z
.object({
street: z.string().min(1, { message: m["form.errors.street"]() }),
number: z.string().min(1, { message: m["form.errors.houseNo"]() }),
additionalAddressInfo: z.string().optional().or(z.literal("")),
zip: z.string().min(4, { message: m["form.errors.zip"]() }),
city: z.string().min(1, { message: m["form.errors.city"]() }),
})
.optional(),
links: z
.object({
website: optionalUrl(m["form.errors.url"]()),
imprint: optionalUrl(m["form.errors.url"]()),
privacyStatement: optionalUrl(m["form.errors.url"]()),
})
.optional(),
settings: z
.object({
autoDeleteDays: z.number().min(30, { message: m["form.errors.deleteAfterDays"]() }),
requirePhone: z.boolean().optional().default(false),
})
.optional(),
})
.superRefine(({ languages, defaultLanguage }, ctx) => {
if (!languages.includes(defaultLanguage)) {
if (languages && defaultLanguage && !languages.includes(defaultLanguage)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m["settings.form.fields.defaultLanguage.errors.notInLanguages"](),
message: m["settings.general.languages.defaultLanguage.errors.notInLanguages"](),
path: ["defaultLanguage"],
});
}
@@ -0,0 +1,98 @@
import { ROUTES } from "$lib/const/routes.js";
import logger from "$lib/logger";
import type { TTenantSettings } from "$lib/types/tenant";
import { redirect } from "@sveltejs/kit";
const log = logger.setContext(import.meta.filename);
export const load = async (event) => {
event.depends(`channel:details`);
// To edit seetings you must have connected tenant
if (!event.locals.user?.tenantId) {
log.warn("No tenant ID found for user while loading settings");
throw redirect(302, ROUTES.DASHBOARD.MAIN);
}
const base = event
.fetch(`/api/tenants/${event.locals.user?.tenantId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
})
.then(async (res) => {
// Logout if session expired
if (res.status === 401) {
redirect(302, ROUTES.LOGOUT);
}
try {
const body = await res.json();
return {
id: body.tenant.id,
languages: body.tenant.languages,
defaultLanguage: body.tenant.defaultLanguage,
shortName: body.tenant.shortName,
longName: body.tenant.longName,
logo: body.tenant.logo || "",
descriptions: body.tenant.descriptions,
links: {
website: body.tenant.links.website || "",
imprint: body.tenant.links.imprint || "",
privacyStatement: body.tenant.links.privacyStatement || "",
},
};
} catch (error) {
log.error("Failed to parse settings base response", { error });
}
});
const config = event
.fetch(`/api/tenants/${event.locals.user?.tenantId}/config`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
})
.then(async (res) => {
// Logout if session expired
if (res.status === 401) {
redirect(302, ROUTES.LOGOUT);
}
try {
const body = await res.json();
return {
address: {
street: body["address.street"] || "",
number: body["address.number"] || "",
additionalAddressInfo: body["address.additionalAddressInfo"] || "",
zip: body["address.zip"] || "",
city: body["address.city"] || "",
},
settings: {
autoDeleteDays: body.autoDeleteDays || 90,
requirePhone: body.requirePhone || false,
},
};
} catch (error) {
log.error("Failed to parse settings config response", { error });
}
});
const item = Promise.all([base, config]).then(([base, config]) => {
return {
...base,
...config,
} as TTenantSettings;
});
return {
streamed: {
item,
},
};
};
@@ -1,103 +1,12 @@
import { ROUTES } from "$lib/const/routes.js";
import logger from "$lib/logger";
import { fail, redirect, type Actions } from "@sveltejs/kit";
import { removeEmptyTranslations } from "$lib/utils/localizations";
import { fail, type Actions } from "@sveltejs/kit";
import { superValidate } from "sveltekit-superforms";
import { zod4 as zod } from "sveltekit-superforms/adapters";
import { formSchema as editFormSchema } from "./(components)/edit-settings-form";
import { removeEmptyTranslations } from "$lib/utils/localizations";
const log = logger.setContext(import.meta.filename);
export const load = async (event) => {
// To edit seetings you must have connected tenant
if (!event.locals.user?.tenantId) {
log.warn("No tenant ID found for user while loading settings");
throw redirect(302, ROUTES.DASHBOARD.MAIN);
}
const base = event
.fetch(`/api/tenants/${event.locals.user?.tenantId}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
})
.then(async (res) => {
// Logout if session expired
if (res.status === 401) {
redirect(302, ROUTES.LOGOUT);
}
try {
const body = await res.json();
return {
id: body.tenant.id,
languages: body.tenant.languages,
defaultLanguage: body.tenant.defaultLanguage,
shortName: body.tenant.shortName,
longName: body.tenant.longName,
logo: body.tenant.logo || "",
descriptions: body.tenant.descriptions,
links: {
website: body.tenant.links.website || "",
imprint: body.tenant.links.imprint || "",
privacyStatement: body.tenant.links.privacyStatement || "",
},
};
} catch (error) {
log.error("Failed to parse settings base response", { error });
}
});
const config = event
.fetch(`/api/tenants/${event.locals.user?.tenantId}/config`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
})
.then(async (res) => {
// Logout if session expired
if (res.status === 401) {
redirect(302, ROUTES.LOGOUT);
}
try {
const body = await res.json();
return {
address: {
street: body["address.street"] || "",
number: body["address.number"] || "",
additionalAddressInfo: body["address.additionalAddressInfo"] || "",
zip: body["address.zip"] || "",
city: body["address.city"] || "",
},
settings: {
autoDeleteDays: body.autoDeleteDays || 90,
requirePhone: body.requirePhone || false,
},
};
} catch (error) {
log.error("Failed to parse settings config response", { error });
}
});
const item = Promise.all([base, config]).then(([base, config]) => {
return {
...base,
...config,
};
});
return {
streamed: {
item,
},
};
};
export const actions: Actions = {
edit: async (event) => {
const form = await superValidate(event, zod(editFormSchema));
@@ -110,44 +19,67 @@ export const actions: Actions = {
});
}
const base = await event.fetch(`/api/tenants/${form.data.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
body: JSON.stringify({
languages: form.data.languages,
defaultLanguage: form.data.defaultLanguage,
longName: form.data.longName,
logo: form.data.logo,
descriptions: removeEmptyTranslations(form.data.descriptions),
links: form.data.links,
}),
});
const requests = [];
if (form.data.longName || form.data.links) {
const base = await event.fetch(`/api/tenants/${form.data.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
body: JSON.stringify({
...(form.data.longName
? {
languages: form.data.languages,
defaultLanguage: form.data.defaultLanguage,
longName: form.data.longName,
logo: form.data.logo,
descriptions: removeEmptyTranslations(form.data.descriptions),
}
: undefined),
...(form.data.links
? {
links: form.data.links,
}
: undefined),
}),
});
requests.push(base);
}
const config = await event.fetch(`/api/tenants/${form.data.id}/config`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
body: JSON.stringify({
"address.street": form.data.address.street,
"address.number": form.data.address.number,
"address.additionalAddressInfo": form.data.address.additionalAddressInfo,
"address.zip": form.data.address.zip,
"address.city": form.data.address.city,
autoDeleteDays: form.data.settings.autoDeleteDays,
requirePhone: form.data.settings.requirePhone ?? false,
}),
});
if (form.data.address || form.data.settings) {
const config = await event.fetch(`/api/tenants/${form.data.id}/config`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
body: JSON.stringify({
...(form.data.address
? {
"address.street": form.data.address.street,
"address.number": form.data.address.number,
"address.additionalAddressInfo": form.data.address.additionalAddressInfo,
"address.zip": form.data.address.zip,
"address.city": form.data.address.city,
}
: undefined),
...(form.data.settings
? {
autoDeleteDays: form.data.settings.autoDeleteDays,
requirePhone: form.data.settings.requirePhone ?? false,
}
: undefined),
}),
});
requests.push(config);
}
const resp = await Promise.all([base, config]).then(([base, config]) => {
if (base.status < 400 && config.status < 400) {
const resp = await Promise.all(requests).then((all) => {
if (all.every((res) => res.status < 400)) {
return { success: true };
}
return { success: false, bodies: { base: base, config: config } };
return { success: false, bodies: { base: all[0], config: all[1] } };
});
if (resp.success) {
@@ -1,14 +1,26 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { m } from "$i18n/messages";
import { MaxPageWidth } from "$lib/components/layouts/max-page-width";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
import { LoadingItemGroup } from "$lib/components/templates/loading/index.js";
import { PageHeadline } from "$lib/components/templates/page-headline";
import { GroupItem } from "$lib/components/ui/group-item";
import * as Item from "$lib/components/ui/item";
import { ROUTES } from "$lib/const/routes";
import { Card } from "$lib/components/ui/card";
import { Headline } from "$lib/components/ui/typography";
import { EditSettingsForm } from "./(components)/edit-settings-form";
import { LoadingCenter } from "$lib/components/templates/loading";
import type { TTenantSettings } from "$lib/types/tenant";
let { data } = $props();
const isAddressComplete = (address: TTenantSettings["address"]) => {
return (
address &&
address.street !== "" &&
address.number !== "" &&
address.zip !== "" &&
address.city !== ""
);
};
</script>
<svelte:head>
@@ -19,18 +31,45 @@
breakcrumbs={[
{
label: m["nav.settings"](),
href: ROUTES.DASHBOARD.SETTINGS,
href: ROUTES.DASHBOARD.SETTINGS.MAIN,
},
]}
>
<MaxPageWidth maxWidth="lg">
<MaxPageWidth maxWidth="lg" class="flex flex-col gap-6">
{#await data.streamed.item}
<LoadingCenter title={m["settings.loading"]()} class="h-[80vh] py-20" />
<LoadingItemGroup title={m["settings.loading"]()} />
{:then item}
<Card>
<Headline style="h4" level="h1">Settings</Headline>
<EditSettingsForm entity={item} />
</Card>
{#if item}
<PageHeadline title={m["settings.overview.title"]()} />
<Item.Group class="gap-2">
<GroupItem
title={m["settings.general.title"]()}
description={m["settings.general.description"]()}
badges={item.longName === ""
? [{ variant: "destructive", label: m["required"]() }]
: undefined}
href={resolve(ROUTES.DASHBOARD.SETTINGS.GENERAL)}
/>
<GroupItem
title={m["settings.address.title"]()}
description={m["settings.address.description"]()}
badges={!isAddressComplete(item.address)
? [{ variant: "destructive", label: m["required"]() }]
: undefined}
href={resolve(ROUTES.DASHBOARD.SETTINGS.ADDRESS)}
/>
<GroupItem
title={m["settings.links.title"]()}
description={m["settings.links.description"]()}
href={resolve(ROUTES.DASHBOARD.SETTINGS.LINKS)}
/>
<GroupItem
title={m["settings.advanced.title"]()}
description={m["settings.advanced.description"]()}
href={resolve(ROUTES.DASHBOARD.SETTINGS.ADVANCED)}
/>
</Item.Group>
{/if}
{/await}
</MaxPageWidth>
</SidebarLayout>
@@ -0,0 +1,43 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { MaxPageWidth } from "$lib/components/layouts/max-page-width";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
import { LoadingDetail } from "$lib/components/templates/loading";
import { PageHeadline } from "$lib/components/templates/page-headline";
import { ROUTES } from "$lib/const/routes";
import { EditSettingsFormAddress } from "../(components)/edit-settings-form";
let { data } = $props();
</script>
<svelte:head>
<title>{m["settings.title"]()}: {m["settings.address.title"]()} - OpenReception</title>
</svelte:head>
<SidebarLayout
breakcrumbs={[
{
label: m["nav.settings"](),
href: ROUTES.DASHBOARD.SETTINGS.MAIN,
},
{
label: m["settings.address.title"](),
href: ROUTES.DASHBOARD.SETTINGS.ADDRESS,
},
]}
>
<MaxPageWidth maxWidth="lg" class="flex flex-col gap-6">
{#await data.streamed.item}
<LoadingDetail title={m["settings.loading"]()} />
{:then item}
{#if item}
<PageHeadline
title={m["settings.address.title"]()}
description={m["settings.address.description"]()}
backHref={ROUTES.DASHBOARD.SETTINGS.MAIN}
/>
<EditSettingsFormAddress entity={item} />
{/if}
{/await}
</MaxPageWidth>
</SidebarLayout>
@@ -0,0 +1,43 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { MaxPageWidth } from "$lib/components/layouts/max-page-width";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
import { LoadingDetail } from "$lib/components/templates/loading";
import { PageHeadline } from "$lib/components/templates/page-headline";
import { ROUTES } from "$lib/const/routes";
import { EditSettingsFormAdvanced } from "../(components)/edit-settings-form";
let { data } = $props();
</script>
<svelte:head>
<title>{m["settings.title"]()}: {m["settings.advanced.title"]()} - OpenReception</title>
</svelte:head>
<SidebarLayout
breakcrumbs={[
{
label: m["nav.settings"](),
href: ROUTES.DASHBOARD.SETTINGS.MAIN,
},
{
label: m["settings.advanced.title"](),
href: ROUTES.DASHBOARD.SETTINGS.ADVANCED,
},
]}
>
<MaxPageWidth maxWidth="lg" class="flex flex-col gap-6">
{#await data.streamed.item}
<LoadingDetail title={m["settings.loading"]()} />
{:then item}
{#if item}
<PageHeadline
title={m["settings.advanced.title"]()}
description={m["settings.advanced.description"]()}
backHref={ROUTES.DASHBOARD.SETTINGS.MAIN}
/>
<EditSettingsFormAdvanced entity={item} />
{/if}
{/await}
</MaxPageWidth>
</SidebarLayout>
@@ -0,0 +1,43 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { MaxPageWidth } from "$lib/components/layouts/max-page-width";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
import { LoadingDetail } from "$lib/components/templates/loading";
import { PageHeadline } from "$lib/components/templates/page-headline";
import { ROUTES } from "$lib/const/routes";
import { EditSettingsFormGeneral } from "../(components)/edit-settings-form";
let { data } = $props();
</script>
<svelte:head>
<title>{m["settings.title"]()}: {m["settings.general.title"]()} - OpenReception</title>
</svelte:head>
<SidebarLayout
breakcrumbs={[
{
label: m["nav.settings"](),
href: ROUTES.DASHBOARD.SETTINGS.MAIN,
},
{
label: m["settings.general.title"](),
href: ROUTES.DASHBOARD.SETTINGS.GENERAL,
},
]}
>
<MaxPageWidth maxWidth="lg" class="flex flex-col gap-6">
{#await data.streamed.item}
<LoadingDetail title={m["settings.loading"]()} />
{:then item}
{#if item}
<PageHeadline
title={m["settings.general.title"]()}
description={m["settings.general.description"]()}
backHref={ROUTES.DASHBOARD.SETTINGS.MAIN}
/>
<EditSettingsFormGeneral entity={item} />
{/if}
{/await}
</MaxPageWidth>
</SidebarLayout>
@@ -0,0 +1,43 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { MaxPageWidth } from "$lib/components/layouts/max-page-width";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
import { LoadingDetail } from "$lib/components/templates/loading";
import { PageHeadline } from "$lib/components/templates/page-headline";
import { ROUTES } from "$lib/const/routes";
import { EditSettingsFormLinks } from "../(components)/edit-settings-form";
let { data } = $props();
</script>
<svelte:head>
<title>{m["settings.title"]()}: {m["settings.links.title"]()} - OpenReception</title>
</svelte:head>
<SidebarLayout
breakcrumbs={[
{
label: m["nav.settings"](),
href: ROUTES.DASHBOARD.SETTINGS.MAIN,
},
{
label: m["settings.links.title"](),
href: ROUTES.DASHBOARD.SETTINGS.LINKS,
},
]}
>
<MaxPageWidth maxWidth="lg" class="flex flex-col gap-6">
{#await data.streamed.item}
<LoadingDetail title={m["settings.loading"]()} />
{:then item}
{#if item}
<PageHeadline
title={m["settings.links.title"]()}
description={m["settings.links.description"]()}
backHref={ROUTES.DASHBOARD.SETTINGS.MAIN}
/>
<EditSettingsFormLinks entity={item} />
{/if}
{/await}
</MaxPageWidth>
</SidebarLayout>
@@ -92,7 +92,6 @@
type: "action",
icon: EditIcon,
label: m["edit"](),
isMainAction: true,
onClick: () => {
curItem = item;
openDialog("edit");
@@ -101,6 +100,7 @@
{
type: "action",
icon: SelectIcon,
isMainAction: true,
label: m["select"](),
onClick: () => tenantsStore.setCurrentTenant(item.id),
},