mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
Merge pull request #175 from open-reception/153-book-appointment-allow-clients-to-select-if-they-want-email-notifications
Added front-end flow to make email sending optional
This commit is contained in:
@@ -6,7 +6,24 @@ on:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
test-and-build:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Run linting
|
||||
run: npm run lint
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -24,9 +41,21 @@ jobs:
|
||||
|
||||
- name: Run type checking
|
||||
run: npm run check
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
- name: Run linting
|
||||
run: npm run lint
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm run test:unit -- --run
|
||||
@@ -41,6 +70,22 @@ jobs:
|
||||
SMTP_FROM_EMAIL: "noreply@example.com"
|
||||
JWT_SECRET: "test"
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build application
|
||||
run: npm run build
|
||||
env:
|
||||
|
||||
Generated
+26
-35
@@ -33,7 +33,7 @@
|
||||
"@eslint/compat": "^1.3.0",
|
||||
"@eslint/js": "^9.29.0",
|
||||
"@inlang/paraglide-js": "2.3.2",
|
||||
"@internationalized/date": "^3.8.2",
|
||||
"@internationalized/date": "^3.10.1",
|
||||
"@lucide/svelte": "^0.544.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@sveltejs/adapter-auto": "^6.1.0",
|
||||
@@ -46,7 +46,7 @@
|
||||
"@types/dotenv": "^6.1.1",
|
||||
"@types/node": "^24",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"bits-ui": "^2.11.4",
|
||||
"bits-ui": "^2.15.4",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-kit": "^0.31.1",
|
||||
"eslint": "^9.29.0",
|
||||
@@ -1673,9 +1673,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@internationalized/date": {
|
||||
"version": "3.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz",
|
||||
"integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==",
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz",
|
||||
"integrity": "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -3674,17 +3674,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bits-ui": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.11.4.tgz",
|
||||
"integrity": "sha512-OlVBJhNUMDHbIAf8oDAyPchIrU8b1S5NAMm6enMZSKx5HKcf/QPI485/BL1r4EPlv4O3m45e59hBRCETtYFdxg==",
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.15.4.tgz",
|
||||
"integrity": "sha512-7H9YUfp03KOk1LVDh8wPYSRPxlZgG/GRWLNSA8QC73/8Z8ytun+DWJhIuibyFyz7A0cP/RANVcB4iDrbY8q+Og==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.1",
|
||||
"@floating-ui/dom": "^1.7.1",
|
||||
"esm-env": "^1.1.2",
|
||||
"runed": "^0.31.1",
|
||||
"svelte-toolbelt": "^0.10.4",
|
||||
"runed": "^0.35.1",
|
||||
"svelte-toolbelt": "^0.10.6",
|
||||
"tabbable": "^6.2.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3699,9 +3699,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/bits-ui/node_modules/runed": {
|
||||
"version": "0.31.1",
|
||||
"resolved": "https://registry.npmjs.org/runed/-/runed-0.31.1.tgz",
|
||||
"integrity": "sha512-v3czcTnO+EJjiPvD4dwIqfTdHLZ8oH0zJheKqAHh9QMViY7Qb29UlAMRpX7ZtHh7AFqV60KmfxaJ9QMy+L1igQ==",
|
||||
"version": "0.35.1",
|
||||
"resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz",
|
||||
"integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/huntabyte",
|
||||
@@ -3709,23 +3709,31 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esm-env": "^1.0.0"
|
||||
"dequal": "^2.0.3",
|
||||
"esm-env": "^1.0.0",
|
||||
"lz-string": "^1.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@sveltejs/kit": "^2.21.0",
|
||||
"svelte": "^5.7.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@sveltejs/kit": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/bits-ui/node_modules/svelte-toolbelt": {
|
||||
"version": "0.10.5",
|
||||
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.5.tgz",
|
||||
"integrity": "sha512-8e+eWTgxw1aiLxhDE8Rb1X6AoLitqpJz+WhAul2W7W58C8KoLoJQf1TgQdFPBiCPJ0Jg5y0Zi1uyua9em4VS0w==",
|
||||
"version": "0.10.6",
|
||||
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
|
||||
"integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/huntabyte"
|
||||
],
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"runed": "^0.29.0",
|
||||
"runed": "^0.35.1",
|
||||
"style-to-object": "^1.0.8"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3736,23 +3744,6 @@
|
||||
"svelte": "^5.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/bits-ui/node_modules/svelte-toolbelt/node_modules/runed": {
|
||||
"version": "0.29.2",
|
||||
"resolved": "https://registry.npmjs.org/runed/-/runed-0.29.2.tgz",
|
||||
"integrity": "sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/huntabyte",
|
||||
"https://github.com/sponsors/tglide"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esm-env": "^1.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
|
||||
+2
-2
@@ -41,7 +41,7 @@
|
||||
"@eslint/compat": "^1.3.0",
|
||||
"@eslint/js": "^9.29.0",
|
||||
"@inlang/paraglide-js": "2.3.2",
|
||||
"@internationalized/date": "^3.8.2",
|
||||
"@internationalized/date": "^3.10.1",
|
||||
"@lucide/svelte": "^0.544.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@sveltejs/adapter-auto": "^6.1.0",
|
||||
@@ -54,7 +54,7 @@
|
||||
"@types/dotenv": "^6.1.1",
|
||||
"@types/node": "^24",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"bits-ui": "^2.11.4",
|
||||
"bits-ui": "^2.15.4",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-kit": "^0.31.1",
|
||||
"eslint": "^9.29.0",
|
||||
|
||||
@@ -844,7 +844,8 @@
|
||||
"description": "Nur Sie und {name} können diese Informationen sehen. Die Daten sind Ende-zu-Ende verschlüsselt."
|
||||
},
|
||||
"email": {
|
||||
"description": "Wir senden Ihnen eine Bestätigungs-E-Mail mit den Termindetails."
|
||||
"notifyMe": "Benachrichtigen Sie mich über Änderungen per E-Mail",
|
||||
"tooltip": "Wenn aktiviert, wird Ihre Mail vorübergehend im Logbuch des Mailservers auftauchen. Wenn deaktiviert müssen Sie sich auf dieser Seite Einloggen, um Änderungen einzusehen."
|
||||
},
|
||||
"phone": {
|
||||
"description": "Format: +491234567890. {name} kontaktiert Sie ggf. telefonisch, falls es Fragen/Änderungen zu Ihrem Termin gibt.",
|
||||
|
||||
@@ -853,7 +853,8 @@
|
||||
"description": "Only you and {name} will be able to access this information. It is end-to-end encrypted."
|
||||
},
|
||||
"email": {
|
||||
"description": "We will use this to send you the appointment details."
|
||||
"notifyMe": "Notify me of changes by e-mail",
|
||||
"tooltip": "If enabled, your e-mail address will appear temporarily in server logs of the mailserver. If disabled, you will have to login on this page to check for updates."
|
||||
},
|
||||
"phone": {
|
||||
"description": "Format: +1234567890. {name} may contact you by phone, if there are questions or changes regarding your appointment.",
|
||||
|
||||
@@ -71,6 +71,7 @@ interface EncryptedData {
|
||||
export interface AppointmentData {
|
||||
name: string;
|
||||
email: string;
|
||||
shareEmail: boolean;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
@@ -319,7 +320,7 @@ export class UnifiedAppointmentCrypto {
|
||||
appointmentDate,
|
||||
duration,
|
||||
emailHash: this.emailHash,
|
||||
clientEmail: appointmentData.email,
|
||||
clientEmail: appointmentData.shareEmail ? appointmentData.email : undefined,
|
||||
clientLanguage,
|
||||
clientPublicKey: this.clientKeyPair?.publicKey,
|
||||
privateKeyShare: await this.getPrivateKeyShare(),
|
||||
@@ -336,7 +337,7 @@ export class UnifiedAppointmentCrypto {
|
||||
channelId,
|
||||
appointmentDate,
|
||||
duration,
|
||||
clientEmail: appointmentData.email,
|
||||
clientEmail: appointmentData.shareEmail ? appointmentData.email : undefined,
|
||||
clientLanguage,
|
||||
encryptedAppointment,
|
||||
};
|
||||
|
||||
@@ -1,31 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox } from "../checkbox";
|
||||
import * as Popover from "$lib/components/ui/popover/index.js";
|
||||
import { cn } from "$lib/utils";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { Text } from "../typography";
|
||||
import type { OnChangeFn } from "vaul-svelte";
|
||||
import { Checkbox } from "../checkbox";
|
||||
import { Text } from "../typography";
|
||||
import { Info } from "@lucide/svelte/icons";
|
||||
import { buttonVariants } from "../button";
|
||||
|
||||
let {
|
||||
class: className,
|
||||
value = $bindable(false),
|
||||
label,
|
||||
tooltip,
|
||||
onCheckedChange,
|
||||
...restProps
|
||||
}: HTMLAttributes<HTMLButtonElement> & {
|
||||
value: boolean;
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
id?: string;
|
||||
onCheckedChange?: OnChangeFn<boolean>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<label class={cn("flex items-start gap-2", className)}>
|
||||
<Checkbox
|
||||
{...restProps}
|
||||
checked={value}
|
||||
value={value ? "true" : "false"}
|
||||
{onCheckedChange}
|
||||
class="mt-0.5"
|
||||
/>
|
||||
<Text style="sm" class="font-normal select-none">{label}</Text>
|
||||
<label class={cn("flex items-center gap-2", className)}>
|
||||
<div class="flex items-start gap-2">
|
||||
<Checkbox
|
||||
{...restProps}
|
||||
checked={value}
|
||||
value={value ? "true" : "false"}
|
||||
{onCheckedChange}
|
||||
class="mt-0.5"
|
||||
/>
|
||||
<Text style="sm" class="font-normal select-none">{label}</Text>
|
||||
</div>
|
||||
{#if tooltip}
|
||||
<Popover.Root>
|
||||
<Popover.Trigger class={cn(buttonVariants({ variant: "ghost", size: "xs" }), "p-1!")}>
|
||||
<Info size="sm" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-65 leading-1">
|
||||
<Text style="xs">
|
||||
{tooltip}
|
||||
</Text>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{cell.char}
|
||||
{cell.char ? "*" : null}
|
||||
{#if cell.hasFakeCaret}
|
||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div class="animate-caret-blink bg-foreground h-4 w-px duration-1000"></div>
|
||||
|
||||
@@ -7,7 +7,13 @@
|
||||
let { value = $bindable(), ...restProps }: Props = $props();
|
||||
</script>
|
||||
|
||||
<InputOTP.Root {...restProps} maxlength={6} bind:value>
|
||||
<InputOTP.Root
|
||||
{...restProps}
|
||||
maxlength={6}
|
||||
type="password"
|
||||
pushPasswordManagerStrategy="none"
|
||||
bind:value
|
||||
>
|
||||
{#snippet children({ cells })}
|
||||
<InputOTP.Group>
|
||||
{#each cells.slice(0, 3) as cell (cell)}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
import Root from "./popover.svelte";
|
||||
import Close from "./popover-close.svelte";
|
||||
import Content from "./popover-content.svelte";
|
||||
import Trigger from "./popover-trigger.svelte";
|
||||
const Root = PopoverPrimitive.Root;
|
||||
const Close = PopoverPrimitive.Close;
|
||||
import Portal from "./popover-portal.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Trigger,
|
||||
Close,
|
||||
Portal,
|
||||
//
|
||||
Root as Popover,
|
||||
Content as PopoverContent,
|
||||
Trigger as PopoverTrigger,
|
||||
Close as PopoverClose,
|
||||
Portal as PopoverPortal,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
import PopoverPortal from "./popover-portal.svelte";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -10,20 +12,20 @@
|
||||
portalProps,
|
||||
...restProps
|
||||
}: PopoverPrimitive.ContentProps & {
|
||||
portalProps?: PopoverPrimitive.PortalProps;
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Portal {...portalProps}>
|
||||
<PopoverPortal {...portalProps}>
|
||||
<PopoverPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="popover-content"
|
||||
{sideOffset}
|
||||
{align}
|
||||
class={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
</PopoverPortal>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Portal {...restProps} />
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from "bits-ui";
|
||||
|
||||
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Root bind:open {...restProps} />
|
||||
@@ -20,7 +20,7 @@ export interface ClientTunnelData {
|
||||
appointmentDate: string;
|
||||
duration: number;
|
||||
emailHash: string;
|
||||
clientEmail: string;
|
||||
clientEmail?: string;
|
||||
clientLanguage?: string;
|
||||
clientPublicKey: string;
|
||||
privateKeyShare: string;
|
||||
@@ -437,19 +437,21 @@ export class AppointmentService {
|
||||
});
|
||||
|
||||
// Send email notification to client (async, don't wait)
|
||||
this.sendAppointmentNotification(
|
||||
result.appointment.id,
|
||||
clientData.channelId,
|
||||
clientData.clientEmail,
|
||||
clientData.clientLanguage || "de",
|
||||
result.requiresConfirmation,
|
||||
).catch((error) => {
|
||||
log.error("Failed to send appointment notification", {
|
||||
tunnelId: clientData.tunnelId,
|
||||
appointmentId: result.appointment.id,
|
||||
error: String(error),
|
||||
if (clientData.clientEmail) {
|
||||
this.sendAppointmentNotification(
|
||||
result.appointment.id,
|
||||
clientData.channelId,
|
||||
clientData.clientEmail,
|
||||
clientData.clientLanguage || "de",
|
||||
result.requiresConfirmation,
|
||||
).catch((error) => {
|
||||
log.error("Failed to send appointment notification", {
|
||||
tunnelId: clientData.tunnelId,
|
||||
appointmentId: result.appointment.id,
|
||||
error: String(error),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export type TPublicAppointment = {
|
||||
};
|
||||
data?: {
|
||||
name: string;
|
||||
shareEmail: boolean;
|
||||
email: string;
|
||||
phone?: string;
|
||||
};
|
||||
|
||||
+33
-15
@@ -1,16 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages.js";
|
||||
import * as Alert from "$lib/components/ui/alert";
|
||||
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 { Text } from "$lib/components/ui/typography";
|
||||
import { publicStore } from "$lib/stores/public";
|
||||
import type { TPublicAppointment } from "$lib/types/public";
|
||||
import { Lock } from "@lucide/svelte";
|
||||
import { get } from "svelte/store";
|
||||
import { superForm } from "sveltekit-superforms";
|
||||
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
|
||||
import { Lock } from "@lucide/svelte";
|
||||
import { publicStore } from "$lib/stores/public";
|
||||
import { createFormSchema } from "./schema";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
const { proceed }: { proceed: (a: Partial<TPublicAppointment>) => void } = $props();
|
||||
const tenant = $derived($publicStore.tenant);
|
||||
@@ -19,6 +20,7 @@
|
||||
const form = superForm(
|
||||
{
|
||||
name: "",
|
||||
shareEmail: false,
|
||||
email: "",
|
||||
phone: get(publicStore).tenant?.requirePhone ? "" : undefined,
|
||||
},
|
||||
@@ -36,6 +38,7 @@
|
||||
...appointment,
|
||||
data: {
|
||||
name: validation.data.name,
|
||||
shareEmail: validation.data.shareEmail,
|
||||
email: validation.data.email,
|
||||
phone: validation.data.phone,
|
||||
},
|
||||
@@ -74,18 +77,33 @@
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="email">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["form.email"]()}</Form.Label>
|
||||
<Input {...props} bind:value={$formData.email} type="email" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
<Form.Description class="mt-1">
|
||||
{m["public.steps.data.email.description"]()}
|
||||
</Form.Description>
|
||||
</Form.Field>
|
||||
<div>
|
||||
<Form.Field {form} name="email">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["form.email"]()}</Form.Label>
|
||||
<Input {...props} bind:value={$formData.email} type="email" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="shareEmail">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<CheckboxWithLabel
|
||||
{...props}
|
||||
bind:value={$formData.shareEmail}
|
||||
label={m["public.steps.data.email.notifyMe"]()}
|
||||
tooltip={m["public.steps.data.email.tooltip"]()}
|
||||
onCheckedChange={(v) => {
|
||||
$formData.shareEmail = v;
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
</div>
|
||||
<Form.Field {form} name="phone">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
|
||||
@@ -5,20 +5,20 @@
|
||||
import * as Form from "$lib/components/ui/form";
|
||||
import type { EventReporter } from "$lib/components/ui/form/form-root.svelte";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { Passkey } from "$lib/components/ui/passkey";
|
||||
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
|
||||
import { Text } from "$lib/components/ui/typography";
|
||||
import { ROUTES } from "$lib/const/routes";
|
||||
import logger from "$lib/logger";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { arrayBufferToBase64, fetchChallenge, getCredential } from "$lib/utils/passkey";
|
||||
import { onMount } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import { type Infer, superForm, type SuperValidated } from "sveltekit-superforms";
|
||||
import { superForm } from "sveltekit-superforms";
|
||||
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
|
||||
import { baseSchema, formSchema, type FormSchema } from "./schema";
|
||||
import { onMount } from "svelte";
|
||||
import { Passkey } from "$lib/components/ui/passkey";
|
||||
import { Text } from "$lib/components/ui/typography";
|
||||
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
|
||||
import { arrayBufferToBase64, fetchChallenge, getCredential } from "$lib/utils/passkey";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import logger from "$lib/logger";
|
||||
import { baseSchema, formSchema } from "./schema";
|
||||
|
||||
let { formId, onEvent }: { formId: string; onEvent: EventReporter } = $props();
|
||||
|
||||
@@ -165,9 +165,6 @@
|
||||
|
||||
const { form: formData, enhance } = form;
|
||||
const passkeyLoading: Writable<PasskeyState> = writable("initial");
|
||||
|
||||
type FormDataPassphrase = Extract<typeof $formData, { type: "passphrase" }>;
|
||||
type FormDataPasskey = Extract<typeof $formData, { type: "passkey" }>;
|
||||
</script>
|
||||
|
||||
<Form.Root {formId} {enhance}>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { setLocale } from "$i18n/runtime";
|
||||
import { Toaster } from "$lib/components/ui/sonner/index.js";
|
||||
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
|
||||
import { ModeWatcher, setMode } from "mode-watcher";
|
||||
import "../app.css";
|
||||
import { setLocale } from "$i18n/runtime";
|
||||
|
||||
let { data, children } = $props();
|
||||
|
||||
@@ -27,4 +28,6 @@
|
||||
|
||||
<ModeWatcher track={false} />
|
||||
<Toaster richColors />
|
||||
{@render children()}
|
||||
<Tooltip.Provider delayDuration={0}>
|
||||
{@render children()}
|
||||
</Tooltip.Provider>
|
||||
|
||||
@@ -140,7 +140,7 @@ describe("POST /api/auth/invite", () => {
|
||||
"STAFF",
|
||||
"12345678-1234-4234-8234-123456789012",
|
||||
"admin-id",
|
||||
"de",
|
||||
"en",
|
||||
);
|
||||
expect(vi.mocked(sendUserInviteEmail)).toHaveBeenCalled();
|
||||
});
|
||||
@@ -174,7 +174,7 @@ describe("POST /api/auth/invite", () => {
|
||||
"STAFF",
|
||||
"12345678-1234-4234-8234-123456789012",
|
||||
"admin-id",
|
||||
"de",
|
||||
"en",
|
||||
);
|
||||
expect(vi.mocked(sendUserInviteEmail)).toHaveBeenCalledWith(
|
||||
"user@example.com",
|
||||
@@ -182,7 +182,7 @@ describe("POST /api/auth/invite", () => {
|
||||
mockTenant,
|
||||
"STAFF",
|
||||
expect.stringContaining("invite-code-123"),
|
||||
"de",
|
||||
"en",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import logger from "$lib/logger";
|
||||
import { checkPermission } from "$lib/server/utils/permissions";
|
||||
|
||||
const requestSchema = z.object({
|
||||
clientEmail: z.string().email().optional(),
|
||||
clientEmail: z.email().optional(),
|
||||
clientLanguage: z.string().optional().default("en"),
|
||||
});
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ const requestSchema = z.object({
|
||||
agentId: z.string(),
|
||||
appointmentDate: z.string(),
|
||||
duration: z.number().int().positive(),
|
||||
clientEmail: z.string().email(),
|
||||
clientEmail: z.email().optional(),
|
||||
clientLanguage: z.string().optional().default("en"),
|
||||
encryptedAppointment: z.object({
|
||||
encryptedPayload: z.string(),
|
||||
@@ -121,7 +121,6 @@ registerOpenAPIRoute("/tenants/{id}/appointments/add-to-tunnel", "POST", {
|
||||
"tunnelId",
|
||||
"channelId",
|
||||
"appointmentDate",
|
||||
"clientEmail",
|
||||
"encryptedAppointment",
|
||||
],
|
||||
},
|
||||
@@ -304,12 +303,6 @@ export const POST: RequestHandler = async ({ request, params }) => {
|
||||
if (!tenant) {
|
||||
logger.warn("Cannot send email: Tenant not found", { tenantId });
|
||||
} else {
|
||||
// Create client data object from request
|
||||
const clientData = {
|
||||
email: validatedData.clientEmail,
|
||||
language: validatedData.clientLanguage,
|
||||
};
|
||||
|
||||
// Get channel title for the email
|
||||
const channelTitle = await getChannelTitle(
|
||||
tenantId,
|
||||
@@ -318,20 +311,28 @@ export const POST: RequestHandler = async ({ request, params }) => {
|
||||
);
|
||||
|
||||
// Send appropriate email based on whether confirmation is required
|
||||
if (requiresConfirmation) {
|
||||
await sendAppointmentRequestEmail(clientData, tenant, result, channelTitle);
|
||||
logger.info("Appointment request email sent", {
|
||||
tunnelId: validatedData.tunnelId,
|
||||
appointmentId: result.id,
|
||||
tenantId,
|
||||
});
|
||||
} else {
|
||||
await sendAppointmentCreatedEmail(clientData, tenant, result, channelTitle);
|
||||
logger.info("Appointment confirmation email sent", {
|
||||
tunnelId: validatedData.tunnelId,
|
||||
appointmentId: result.id,
|
||||
tenantId,
|
||||
});
|
||||
if (validatedData.clientEmail) {
|
||||
// Create client data object from request
|
||||
const clientData = {
|
||||
email: validatedData.clientEmail,
|
||||
language: validatedData.clientLanguage,
|
||||
};
|
||||
|
||||
if (requiresConfirmation) {
|
||||
await sendAppointmentRequestEmail(clientData, tenant, result, channelTitle);
|
||||
logger.info("Appointment request email sent", {
|
||||
tunnelId: validatedData.tunnelId,
|
||||
appointmentId: result.id,
|
||||
tenantId,
|
||||
});
|
||||
} else {
|
||||
await sendAppointmentCreatedEmail(clientData, tenant, result, channelTitle);
|
||||
logger.info("Appointment confirmation email sent", {
|
||||
tunnelId: validatedData.tunnelId,
|
||||
appointmentId: result.id,
|
||||
tenantId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (emailError) {
|
||||
|
||||
@@ -12,7 +12,7 @@ const requestSchema = z.object({
|
||||
appointmentDate: z.string(),
|
||||
duration: z.number().int().positive(),
|
||||
emailHash: z.string(),
|
||||
clientEmail: z.string().email(),
|
||||
clientEmail: z.email().optional(),
|
||||
clientLanguage: z.string().optional().default("en"),
|
||||
clientPublicKey: z.string(),
|
||||
privateKeyShare: z.string(),
|
||||
@@ -74,7 +74,7 @@ registerOpenAPIRoute("/tenants/{id}/appointments/create-new-client", "POST", {
|
||||
clientEmail: {
|
||||
type: "string",
|
||||
format: "email",
|
||||
description: "Client email address for sending confirmation",
|
||||
description: "Client email address for sending confirmation. Optional.",
|
||||
example: "client@example.com",
|
||||
},
|
||||
clientLanguage: {
|
||||
@@ -138,7 +138,6 @@ registerOpenAPIRoute("/tenants/{id}/appointments/create-new-client", "POST", {
|
||||
"channelId",
|
||||
"appointmentDate",
|
||||
"emailHash",
|
||||
"clientEmail",
|
||||
"clientPublicKey",
|
||||
"privateKeyShare",
|
||||
"encryptedAppointment",
|
||||
|
||||
Reference in New Issue
Block a user