wip
This commit is contained in:
@@ -24,6 +24,19 @@ function createAuth() {
|
||||
// Staff accounts are provisioned via the seed script, not public self sign-up.
|
||||
disableSignUp: true,
|
||||
},
|
||||
user: {
|
||||
additionalFields: {
|
||||
// `required` here only governs Better Auth's own self-service sign-up/
|
||||
// update-user flows, which this app doesn't use for staff — accounts go
|
||||
// through admin.createUser, whose `data` field isn't schema-validated by
|
||||
// Better Auth, so the actual "required" enforcement lives in the staff
|
||||
// team UI (staff.team.tsx) instead.
|
||||
phone: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 12,
|
||||
},
|
||||
|
||||
+8
-3
@@ -1,10 +1,15 @@
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { createServerFn, createServerOnlyFn } from '@tanstack/react-start'
|
||||
import { getRequest } from '@tanstack/react-start/server'
|
||||
|
||||
export async function getStaffSession() {
|
||||
// `staff.index.tsx` and friends import `getCurrentStaffUser` from this module for
|
||||
// their `beforeLoad` checks, which pulls the whole file into the client bundle too.
|
||||
// `createServerOnlyFn` tells the compiler this closure (and the server-only
|
||||
// `getRequest` import it uses) can be stripped from that client bundle instead of
|
||||
// dragging the Postgres driver into the browser.
|
||||
export const getStaffSession = createServerOnlyFn(async function getStaffSession() {
|
||||
const { getAuth } = await import('#/lib/auth-instance')
|
||||
return getAuth().api.getSession({ headers: getRequest().headers })
|
||||
}
|
||||
})
|
||||
|
||||
export async function requireStaffSession() {
|
||||
const session = await getStaffSession()
|
||||
|
||||
+6
-1
@@ -1,4 +1,9 @@
|
||||
export function formatDateOnly(date: Date) {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
export function formatDateTimeLocal(date: Date) {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
return `${formatDateOnly(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { Resend } from 'resend'
|
||||
|
||||
import type { RestaurantTable } from '#/db/schema'
|
||||
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'full',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
const COLORS = {
|
||||
bg: '#17120f',
|
||||
bgRaised: '#1e1712',
|
||||
line: '#3a2f26',
|
||||
text: '#f3ead9',
|
||||
textMuted: '#c9baa3',
|
||||
gold: '#c9a15a',
|
||||
goldBright: '#e0bb78',
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
let resendClient: Resend | undefined
|
||||
|
||||
function getResendClient() {
|
||||
const apiKey = process.env.RESEND_API_KEY
|
||||
|
||||
if (!apiKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
resendClient ??= new Resend(apiKey)
|
||||
return resendClient
|
||||
}
|
||||
|
||||
type ReservationEmailInfo = {
|
||||
customerName: string
|
||||
customerEmail: string
|
||||
message?: string | null
|
||||
partySize: number
|
||||
reservationTime: Date
|
||||
cancelToken: string
|
||||
table: Pick<RestaurantTable, 'name'>
|
||||
}
|
||||
|
||||
const CONTACT_EMAIL = 'info@steakhaus-meppen.de'
|
||||
|
||||
function getSiteUrl() {
|
||||
return process.env.BETTER_AUTH_URL ?? 'http://localhost:3000'
|
||||
}
|
||||
|
||||
function buildCancelUrl(cancelToken: string) {
|
||||
return `${getSiteUrl()}/stornieren/${cancelToken}`
|
||||
}
|
||||
|
||||
function renderReservationEmailHtml(
|
||||
heading: string,
|
||||
intro: string,
|
||||
reservation: ReservationEmailInfo,
|
||||
showActions: boolean,
|
||||
) {
|
||||
const formattedTime = dateTimeFormatter.format(reservation.reservationTime)
|
||||
const name = escapeHtml(reservation.customerName)
|
||||
const tableName = escapeHtml(reservation.table.name)
|
||||
const cancelUrl = buildCancelUrl(reservation.cancelToken)
|
||||
|
||||
const row = (label: string, value: string) => `
|
||||
<tr>
|
||||
<td style="padding:10px 0;border-top:1px solid ${COLORS.line};color:${COLORS.textMuted};font:400 14px/1.4 'Helvetica Neue',Arial,sans-serif;letter-spacing:0.03em;text-transform:uppercase;white-space:nowrap;">
|
||||
${label}
|
||||
</td>
|
||||
<td style="padding:10px 0;border-top:1px solid ${COLORS.line};color:${COLORS.text};font:600 15px/1.4 Georgia,'Times New Roman',serif;text-align:right;">
|
||||
${value}
|
||||
</td>
|
||||
</tr>`
|
||||
|
||||
return `
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${heading}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:32px 16px;background:${COLORS.bg};font:400 16px/1.5 'Helvetica Neue',Arial,sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:480px;">
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom:28px;">
|
||||
<span style="font:600 20px/1.3 Georgia,'Times New Roman',serif;letter-spacing:0.03em;color:${COLORS.text};">
|
||||
Steakhaus <span style="color:${COLORS.goldBright};">bei Mirko</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background:${COLORS.bgRaised};border:1px solid ${COLORS.line};border-radius:10px;padding:32px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom:20px;">
|
||||
<span style="display:inline-block;width:36px;height:2px;background:${COLORS.gold};"></span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font:600 22px/1.3 Georgia,'Times New Roman',serif;color:${COLORS.goldBright};padding-bottom:12px;">
|
||||
${heading}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:${COLORS.textMuted};font-size:15px;padding-bottom:8px;">
|
||||
Hallo ${name},
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:${COLORS.textMuted};font-size:15px;padding-bottom:20px;">
|
||||
${intro}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
${row('Tisch', tableName)}
|
||||
${row('Datum & Uhrzeit', formattedTime)}
|
||||
${row('Personen', String(reservation.partySize))}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
${
|
||||
reservation.message
|
||||
? `
|
||||
<tr>
|
||||
<td style="padding-top:16px;">
|
||||
<div style="color:${COLORS.textMuted};font:400 12px/1.4 'Helvetica Neue',Arial,sans-serif;letter-spacing:0.03em;text-transform:uppercase;padding-bottom:4px;">
|
||||
Nachricht
|
||||
</div>
|
||||
<div style="color:${COLORS.text};font-size:14px;">${escapeHtml(reservation.message)}</div>
|
||||
</td>
|
||||
</tr>`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
showActions
|
||||
? `
|
||||
<tr>
|
||||
<td align="center" style="padding-top:28px;">
|
||||
<a href="${cancelUrl}" style="display:inline-block;padding:0.7rem 1.4rem;border:1px solid ${COLORS.line};border-radius:4px;color:${COLORS.text};font:600 13px/1 'Helvetica Neue',Arial,sans-serif;text-decoration:none;">
|
||||
Reservierung stornieren
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding-top:16px;color:${COLORS.textMuted};font-size:13px;">
|
||||
Möchten Sie etwas ändern? Schreiben Sie uns einfach eine E-Mail an
|
||||
<a href="mailto:${CONTACT_EMAIL}" style="color:${COLORS.goldBright};">${CONTACT_EMAIL}</a>.
|
||||
</td>
|
||||
</tr>`
|
||||
: ''
|
||||
}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding-top:28px;color:${COLORS.textMuted};font-size:13px;">
|
||||
Wir freuen uns auf Sie!
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding-top:16px;color:${COLORS.textMuted};font-size:12px;">
|
||||
Meppener Straße 8 · 49716 Meppen · 05931 - 12750
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
function renderReservationEmailText(
|
||||
heading: string,
|
||||
intro: string,
|
||||
reservation: ReservationEmailInfo,
|
||||
showActions: boolean,
|
||||
) {
|
||||
const formattedTime = dateTimeFormatter.format(reservation.reservationTime)
|
||||
|
||||
return [
|
||||
'Steakhaus bei Mirko',
|
||||
'',
|
||||
heading,
|
||||
'',
|
||||
`Hallo ${reservation.customerName},`,
|
||||
'',
|
||||
intro,
|
||||
'',
|
||||
`Tisch: ${reservation.table.name}`,
|
||||
`Datum & Uhrzeit: ${formattedTime}`,
|
||||
`Personen: ${reservation.partySize}`,
|
||||
...(reservation.message ? [`Nachricht: ${reservation.message}`] : []),
|
||||
'',
|
||||
...(showActions
|
||||
? [
|
||||
`Reservierung stornieren: ${buildCancelUrl(reservation.cancelToken)}`,
|
||||
`Möchten Sie etwas ändern? Schreiben Sie uns eine E-Mail an ${CONTACT_EMAIL}.`,
|
||||
'',
|
||||
]
|
||||
: []),
|
||||
'Wir freuen uns auf Sie!',
|
||||
'Steakhaus bei Mirko',
|
||||
'Meppener Straße 8 · 49716 Meppen · 05931 - 12750',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function sendReservationEmail(
|
||||
subject: string,
|
||||
intro: string,
|
||||
reservation: ReservationEmailInfo,
|
||||
showActions: boolean,
|
||||
) {
|
||||
const client = getResendClient()
|
||||
|
||||
if (!client) {
|
||||
console.warn('RESEND_API_KEY is not set — skipping reservation email')
|
||||
return
|
||||
}
|
||||
|
||||
const fromAddress = process.env.RESERVATIONS_FROM_EMAIL ?? 'Steakhaus bei Mirko <reservierungen@steakhaus-meppen.de>'
|
||||
|
||||
try {
|
||||
await client.emails.send({
|
||||
from: fromAddress,
|
||||
to: reservation.customerEmail,
|
||||
subject,
|
||||
html: renderReservationEmailHtml(subject, intro, reservation, showActions),
|
||||
text: renderReservationEmailText(subject, intro, reservation, showActions),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to send reservation email', err)
|
||||
}
|
||||
}
|
||||
|
||||
export function sendReservationConfirmationEmail(reservation: ReservationEmailInfo) {
|
||||
return sendReservationEmail(
|
||||
'Ihre Reservierung ist bestätigt',
|
||||
'vielen Dank für Ihre Reservierung. Hier sind die Details:',
|
||||
reservation,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
export function sendReservationUpdateEmail(reservation: ReservationEmailInfo) {
|
||||
return sendReservationEmail(
|
||||
'Ihre Reservierung wurde geändert',
|
||||
'Ihre Reservierung wurde aktualisiert. Hier sind die neuen Details:',
|
||||
reservation,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
export function sendReservationCancellationEmail(reservation: ReservationEmailInfo) {
|
||||
return sendReservationEmail(
|
||||
'Ihre Reservierung wurde storniert',
|
||||
'Ihre folgende Reservierung wurde storniert:',
|
||||
reservation,
|
||||
false,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { formatDateOnly } from '#/lib/datetime'
|
||||
|
||||
export const RESTAURANT_TIME_ZONE = 'Europe/Berlin'
|
||||
export const RESERVATION_INTERVAL_MINUTES = 15
|
||||
|
||||
export type OpeningRange = { start: string; end: string }
|
||||
|
||||
// Keyed by the JS `Date#getDay()` convention: 0 = Sunday … 6 = Saturday.
|
||||
const OPENING_HOURS_BY_WEEKDAY: Record<number, OpeningRange[]> = {
|
||||
0: [
|
||||
{ start: '11:30', end: '13:30' },
|
||||
{ start: '17:00', end: '22:00' },
|
||||
],
|
||||
1: [],
|
||||
2: [{ start: '17:00', end: '21:30' }],
|
||||
3: [{ start: '17:00', end: '21:30' }],
|
||||
4: [{ start: '17:00', end: '21:30' }],
|
||||
5: [{ start: '17:00', end: '22:00' }],
|
||||
6: [{ start: '17:00', end: '22:00' }],
|
||||
}
|
||||
|
||||
const WEEKDAY_LABELS_MONDAY_FIRST: Array<{ day: string; weekday: number }> = [
|
||||
{ day: 'Montag', weekday: 1 },
|
||||
{ day: 'Dienstag', weekday: 2 },
|
||||
{ day: 'Mittwoch', weekday: 3 },
|
||||
{ day: 'Donnerstag', weekday: 4 },
|
||||
{ day: 'Freitag', weekday: 5 },
|
||||
{ day: 'Samstag', weekday: 6 },
|
||||
{ day: 'Sonntag', weekday: 0 },
|
||||
]
|
||||
|
||||
const WEEKDAY_SHORT_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
const berlinPartsFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: RESTAURANT_TIME_ZONE,
|
||||
weekday: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
})
|
||||
|
||||
function toMinutes(time: string) {
|
||||
const [hours, minutes] = time.split(':').map(Number)
|
||||
return hours * 60 + minutes
|
||||
}
|
||||
|
||||
function getBerlinTimeParts(date: Date) {
|
||||
const parts = berlinPartsFormatter.formatToParts(date)
|
||||
const byType = Object.fromEntries(parts.map((part) => [part.type, part.value]))
|
||||
return {
|
||||
weekday: WEEKDAY_SHORT_NAMES.indexOf(byType.weekday),
|
||||
minutesSinceMidnight: Number(byType.hour) * 60 + Number(byType.minute),
|
||||
}
|
||||
}
|
||||
|
||||
export function isOnReservationInterval(date: Date, intervalMinutes: number = RESERVATION_INTERVAL_MINUTES) {
|
||||
return date.getUTCMinutes() % intervalMinutes === 0 && date.getUTCSeconds() === 0
|
||||
}
|
||||
|
||||
// `durationMinutes` requires the whole reservation — not just its start — to fit
|
||||
// before closing, so a slot too close to close time is rejected outright.
|
||||
export function isWithinOpeningHours(date: Date, durationMinutes = 0) {
|
||||
const { weekday, minutesSinceMidnight } = getBerlinTimeParts(date)
|
||||
const ranges = OPENING_HOURS_BY_WEEKDAY[weekday] ?? []
|
||||
|
||||
return ranges.some(
|
||||
(range) =>
|
||||
minutesSinceMidnight >= toMinutes(range.start) &&
|
||||
minutesSinceMidnight + durationMinutes <= toMinutes(range.end),
|
||||
)
|
||||
}
|
||||
|
||||
export function getOpeningHoursForDisplay() {
|
||||
return WEEKDAY_LABELS_MONDAY_FIRST.map(({ day, weekday }) => {
|
||||
const ranges = OPENING_HOURS_BY_WEEKDAY[weekday]
|
||||
return {
|
||||
day,
|
||||
hours: ranges.length ? ranges.map((range) => `${range.start} – ${range.end}`) : null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getTodayLabelBerlin() {
|
||||
return new Intl.DateTimeFormat('de-DE', { timeZone: RESTAURANT_TIME_ZONE, weekday: 'long' }).format(new Date())
|
||||
}
|
||||
|
||||
// The functions below drive the customer-facing date/time picker. They work with the
|
||||
// browser's local time (not RESTAURANT_TIME_ZONE) since visitors are assumed to be
|
||||
// browsing from the restaurant's own timezone — same assumption the rest of the
|
||||
// booking form already makes when it builds a Date from an unqualified local string.
|
||||
|
||||
export function getOpeningRangesForWeekday(weekday: number): OpeningRange[] {
|
||||
return OPENING_HOURS_BY_WEEKDAY[weekday] ?? []
|
||||
}
|
||||
|
||||
// `durationMinutes` (how long a reservation booked at a slot would occupy the
|
||||
// table) defaults to `intervalMinutes` so a bare call still behaves sensibly —
|
||||
// but every real caller should pass the restaurant's actual reservation
|
||||
// duration, so a slot too close to closing time to fit the whole reservation
|
||||
// is excluded rather than offered and then silently running past close.
|
||||
export function generateTimeOptions(
|
||||
ranges: OpeningRange[],
|
||||
intervalMinutes: number = RESERVATION_INTERVAL_MINUTES,
|
||||
durationMinutes: number = intervalMinutes,
|
||||
): string[] {
|
||||
const options: string[] = []
|
||||
|
||||
for (const range of ranges) {
|
||||
const startMinutes = toMinutes(range.start)
|
||||
const endMinutes = toMinutes(range.end)
|
||||
|
||||
for (let minutes = startMinutes; minutes + durationMinutes <= endMinutes; minutes += intervalMinutes) {
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remainder = minutes % 60
|
||||
options.push(`${String(hours).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`)
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
export function getNextAvailableSlot(
|
||||
from: Date,
|
||||
intervalMinutes: number = RESERVATION_INTERVAL_MINUTES,
|
||||
durationMinutes: number = intervalMinutes,
|
||||
): { date: string; time: string } | null {
|
||||
for (let dayOffset = 0; dayOffset < 14; dayOffset++) {
|
||||
const day = new Date(from)
|
||||
day.setDate(from.getDate() + dayOffset)
|
||||
|
||||
const dateStr = formatDateOnly(day)
|
||||
const options = generateTimeOptions(getOpeningRangesForWeekday(day.getDay()), intervalMinutes, durationMinutes)
|
||||
|
||||
for (const time of options) {
|
||||
const slot = new Date(`${dateStr}T${time}`)
|
||||
if (slot.getTime() >= from.getTime()) {
|
||||
return { date: dateStr, time }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// Shared between the customer reservation form (lib/reservations.ts) and the
|
||||
// staff phone field (routes/staff.team.tsx), so "a valid phone number" means
|
||||
// the same thing everywhere in the app.
|
||||
export const phoneSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(30, 'Enter a valid phone number')
|
||||
// Optional leading `+` plus digits and common separators (spaces, dashes,
|
||||
// dots, parentheses) — permissive about international formats, but the
|
||||
// character class alone would still accept e.g. "()---" or "+", so the
|
||||
// refine below requires it to actually contain a real number of digits.
|
||||
.regex(/^\+?[0-9()\-.\s]+$/, 'Enter a valid phone number')
|
||||
.refine((value) => (value.match(/\d/g)?.length ?? 0) >= 6, 'Enter a valid phone number')
|
||||
+378
-26
@@ -1,15 +1,205 @@
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { and, eq, ne } from 'drizzle-orm'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { db } from '#/db'
|
||||
import { reservations, restaurantTables } from '#/db/schema'
|
||||
import { requireStaffSession } from '#/lib/auth'
|
||||
import {
|
||||
sendReservationCancellationEmail,
|
||||
sendReservationConfirmationEmail,
|
||||
sendReservationUpdateEmail,
|
||||
} from '#/lib/email'
|
||||
import { isOnReservationInterval, isWithinOpeningHours } from '#/lib/opening-hours'
|
||||
import { phoneSchema } from '#/lib/phone'
|
||||
import { zodValidator } from '#/lib/server-validator'
|
||||
import { loadReservationSettings } from '#/lib/settings'
|
||||
import { dataUrlImageSchema } from '#/lib/table-image'
|
||||
|
||||
function formatLeadTime(minutes: number) {
|
||||
if (minutes % 60 === 0) {
|
||||
const hours = minutes / 60
|
||||
return `${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`
|
||||
}
|
||||
return `${minutes} Minuten`
|
||||
}
|
||||
|
||||
function assertValidReservationSlot(
|
||||
reservationTime: Date,
|
||||
intervalMinutes: number,
|
||||
minLeadTimeMinutes: number,
|
||||
maxLeadTimeDays: number,
|
||||
durationMinutes: number,
|
||||
) {
|
||||
const earliestAllowed = Date.now() + minLeadTimeMinutes * 60_000
|
||||
|
||||
if (reservationTime.getTime() < earliestAllowed) {
|
||||
throw new Error(
|
||||
minLeadTimeMinutes > 0
|
||||
? `Reservierungen müssen mindestens ${formatLeadTime(minLeadTimeMinutes)} im Voraus gebucht werden`
|
||||
: 'Die Reservierungszeit muss in der Zukunft liegen',
|
||||
)
|
||||
}
|
||||
|
||||
const latestAllowed = Date.now() + maxLeadTimeDays * 24 * 60 * 60_000
|
||||
|
||||
if (reservationTime.getTime() > latestAllowed) {
|
||||
throw new Error(`Reservierungen sind höchstens ${maxLeadTimeDays} Tage im Voraus möglich`)
|
||||
}
|
||||
|
||||
if (!isOnReservationInterval(reservationTime, intervalMinutes)) {
|
||||
throw new Error(`Reservierungen sind nur im ${intervalMinutes}-Minuten-Raster möglich`)
|
||||
}
|
||||
|
||||
if (!isWithinOpeningHours(reservationTime, durationMinutes)) {
|
||||
throw new Error('Zu dieser Zeit reicht die verbleibende Zeit bis zum Schließen nicht mehr aus')
|
||||
}
|
||||
}
|
||||
|
||||
// A table is occupied for its own configured duration, not just the exact minute it
|
||||
// was booked for — so overlapping bookings (not just identical ones) are conflicts.
|
||||
// Each reservation's own stored duration is used (not the current setting), since an
|
||||
// older booking keeps whatever duration was in effect when it was made. `bufferMinutes`
|
||||
// (the current minLeadTimeMinutes setting, reused here as a table turnover gap) is
|
||||
// added on both sides of the existing reservation, so back-to-back bookings need at
|
||||
// least that much breathing room between one guest leaving and the next arriving.
|
||||
async function findOverlappingReservation(
|
||||
tableId: number,
|
||||
start: Date,
|
||||
durationMinutes: number,
|
||||
bufferMinutes: number,
|
||||
excludeId?: number,
|
||||
) {
|
||||
const end = start.getTime() + durationMinutes * 60_000
|
||||
const bufferMs = bufferMinutes * 60_000
|
||||
const candidates = await db.select().from(reservations).where(eq(reservations.tableId, tableId))
|
||||
|
||||
return candidates.find((r) => {
|
||||
if (excludeId !== undefined && r.id === excludeId) return false
|
||||
const existingStart = r.reservationTime.getTime() - bufferMs
|
||||
const existingEnd = existingStart + r.durationMinutes * 60_000 + 2 * bufferMs
|
||||
return existingStart < end && start.getTime() < existingEnd
|
||||
})
|
||||
}
|
||||
|
||||
export const getTables = createServerFn({ method: 'GET' }).handler(async () => {
|
||||
return db.select().from(restaurantTables).orderBy(restaurantTables.name)
|
||||
})
|
||||
|
||||
const tableSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Name ist erforderlich').max(120),
|
||||
tableNumber: z.number().int().positive('Tischnummer muss positiv sein'),
|
||||
seats: z.number().int().min(1, 'Mindestens 1 Platz').max(50),
|
||||
imageUrl: dataUrlImageSchema,
|
||||
})
|
||||
|
||||
export const createTable = createServerFn({ method: 'POST' })
|
||||
.validator(zodValidator(tableSchema))
|
||||
.handler(async ({ data }) => {
|
||||
const session = await requireStaffSession()
|
||||
if (session.user.role !== 'admin') {
|
||||
throw new Error('Nur Admins dürfen Tische verwalten')
|
||||
}
|
||||
|
||||
const [table] = await db.insert(restaurantTables).values(data).returning()
|
||||
return table
|
||||
})
|
||||
|
||||
const updateTableSchema = tableSchema.extend({
|
||||
id: z.number().int().positive(),
|
||||
})
|
||||
|
||||
export const updateTable = createServerFn({ method: 'POST' })
|
||||
.validator(zodValidator(updateTableSchema))
|
||||
.handler(async ({ data }) => {
|
||||
const session = await requireStaffSession()
|
||||
if (session.user.role !== 'admin') {
|
||||
throw new Error('Nur Admins dürfen Tische verwalten')
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(restaurantTables)
|
||||
.set({ name: data.name, tableNumber: data.tableNumber, seats: data.seats, imageUrl: data.imageUrl })
|
||||
.where(eq(restaurantTables.id, data.id))
|
||||
.returning()
|
||||
|
||||
if (!updated) {
|
||||
throw new Error('Tisch wurde nicht gefunden')
|
||||
}
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
const deleteTableSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
})
|
||||
|
||||
export const deleteTable = createServerFn({ method: 'POST' })
|
||||
.validator(zodValidator(deleteTableSchema))
|
||||
.handler(async ({ data }) => {
|
||||
const session = await requireStaffSession()
|
||||
if (session.user.role !== 'admin') {
|
||||
throw new Error('Nur Admins dürfen Tische verwalten')
|
||||
}
|
||||
|
||||
// The FK from reservations to this table cascades on delete, which would
|
||||
// silently wipe out (and never notify) any guest booked on it. Deleting
|
||||
// reservations individually first goes through deleteReservation, which
|
||||
// does send a cancellation email.
|
||||
const [existingReservation] = await db
|
||||
.select({ id: reservations.id })
|
||||
.from(reservations)
|
||||
.where(eq(reservations.tableId, data.id))
|
||||
.limit(1)
|
||||
|
||||
if (existingReservation) {
|
||||
throw new Error(
|
||||
'Tisch kann nicht gelöscht werden, solange Reservierungen dafür bestehen — bitte zuerst verschieben oder löschen',
|
||||
)
|
||||
}
|
||||
|
||||
const [deleted] = await db.delete(restaurantTables).where(eq(restaurantTables.id, data.id)).returning()
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error('Tisch wurde nicht gefunden')
|
||||
}
|
||||
|
||||
return deleted
|
||||
})
|
||||
|
||||
const setTableMarkerSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
// Both coordinates set together (a marker) or both cleared (none) — there's no
|
||||
// meaningful state for only one of them being present.
|
||||
marker: z
|
||||
.object({
|
||||
x: z.number().min(0).max(100),
|
||||
y: z.number().min(0).max(100),
|
||||
})
|
||||
.nullable(),
|
||||
})
|
||||
|
||||
export const setTableMarker = createServerFn({ method: 'POST' })
|
||||
.validator(zodValidator(setTableMarkerSchema))
|
||||
.handler(async ({ data }) => {
|
||||
const session = await requireStaffSession()
|
||||
if (session.user.role !== 'admin') {
|
||||
throw new Error('Nur Admins dürfen Tische verwalten')
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(restaurantTables)
|
||||
.set({ markerX: data.marker?.x ?? null, markerY: data.marker?.y ?? null })
|
||||
.where(eq(restaurantTables.id, data.id))
|
||||
.returning()
|
||||
|
||||
if (!updated) {
|
||||
throw new Error('Tisch wurde nicht gefunden')
|
||||
}
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
export const getReservations = createServerFn({ method: 'GET' }).handler(async () => {
|
||||
await requireStaffSession()
|
||||
|
||||
@@ -18,20 +208,45 @@ export const getReservations = createServerFn({ method: 'GET' }).handler(async (
|
||||
id: reservations.id,
|
||||
tableId: reservations.tableId,
|
||||
tableName: restaurantTables.name,
|
||||
tableNumber: restaurantTables.tableNumber,
|
||||
customerName: reservations.customerName,
|
||||
customerEmail: reservations.customerEmail,
|
||||
customerPhone: reservations.customerPhone,
|
||||
message: reservations.message,
|
||||
partySize: reservations.partySize,
|
||||
status: reservations.status,
|
||||
reservationTime: reservations.reservationTime,
|
||||
durationMinutes: reservations.durationMinutes,
|
||||
})
|
||||
.from(reservations)
|
||||
.innerJoin(restaurantTables, eq(reservations.tableId, restaurantTables.id))
|
||||
.orderBy(reservations.reservationTime)
|
||||
})
|
||||
|
||||
// Unauthenticated — powers the public booking form's time picker, so it only
|
||||
// exposes what's needed to block already-taken slots (table + time span), never
|
||||
// any customer data.
|
||||
export const getReservationAvailability = createServerFn({ method: 'GET' }).handler(async () => {
|
||||
return db
|
||||
.select({
|
||||
tableId: reservations.tableId,
|
||||
reservationTime: reservations.reservationTime,
|
||||
durationMinutes: reservations.durationMinutes,
|
||||
})
|
||||
.from(reservations)
|
||||
})
|
||||
|
||||
const createReservationSchema = z.object({
|
||||
tableId: z.number().int().positive(),
|
||||
customerName: z.string().trim().min(1, 'Name is required').max(120),
|
||||
customerEmail: z.string().trim().email('Enter a valid email'),
|
||||
customerPhone: phoneSchema,
|
||||
message: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(500, 'Message must be at most 500 characters')
|
||||
.optional()
|
||||
.transform((value) => value || null),
|
||||
partySize: z.number().int().min(1, 'At least 1 guest').max(20),
|
||||
reservationTime: z.string().refine((value) => !Number.isNaN(Date.parse(value)), {
|
||||
message: 'Enter a valid date and time',
|
||||
@@ -39,13 +254,18 @@ const createReservationSchema = z.object({
|
||||
})
|
||||
|
||||
export const createReservation = createServerFn({ method: 'POST' })
|
||||
.validator(createReservationSchema)
|
||||
.validator(zodValidator(createReservationSchema))
|
||||
.handler(async ({ data }) => {
|
||||
const settings = await loadReservationSettings()
|
||||
const reservationTime = new Date(data.reservationTime)
|
||||
|
||||
if (reservationTime.getTime() < Date.now()) {
|
||||
throw new Error('Reservation time must be in the future')
|
||||
}
|
||||
assertValidReservationSlot(
|
||||
reservationTime,
|
||||
settings.reservationIntervalMinutes,
|
||||
settings.minLeadTimeMinutes,
|
||||
settings.maxLeadTimeDays,
|
||||
settings.reservationDurationMinutes,
|
||||
)
|
||||
|
||||
const [table] = await db
|
||||
.select()
|
||||
@@ -60,15 +280,12 @@ export const createReservation = createServerFn({ method: 'POST' })
|
||||
throw new Error(`${table.name} only seats up to ${table.seats} guests`)
|
||||
}
|
||||
|
||||
const [conflict] = await db
|
||||
.select()
|
||||
.from(reservations)
|
||||
.where(
|
||||
and(
|
||||
eq(reservations.tableId, data.tableId),
|
||||
eq(reservations.reservationTime, reservationTime),
|
||||
),
|
||||
)
|
||||
const conflict = await findOverlappingReservation(
|
||||
data.tableId,
|
||||
reservationTime,
|
||||
settings.reservationDurationMinutes,
|
||||
settings.minLeadTimeMinutes,
|
||||
)
|
||||
|
||||
if (conflict) {
|
||||
throw new Error('This table is already booked for that time')
|
||||
@@ -80,11 +297,24 @@ export const createReservation = createServerFn({ method: 'POST' })
|
||||
tableId: data.tableId,
|
||||
customerName: data.customerName,
|
||||
customerEmail: data.customerEmail,
|
||||
customerPhone: data.customerPhone,
|
||||
message: data.message,
|
||||
durationMinutes: settings.reservationDurationMinutes,
|
||||
partySize: data.partySize,
|
||||
reservationTime,
|
||||
})
|
||||
.returning()
|
||||
|
||||
await sendReservationConfirmationEmail({
|
||||
customerName: reservation.customerName,
|
||||
customerEmail: reservation.customerEmail,
|
||||
message: reservation.message,
|
||||
partySize: reservation.partySize,
|
||||
reservationTime: reservation.reservationTime,
|
||||
cancelToken: reservation.cancelToken,
|
||||
table,
|
||||
})
|
||||
|
||||
return reservation
|
||||
})
|
||||
|
||||
@@ -93,12 +323,21 @@ const updateReservationSchema = createReservationSchema.extend({
|
||||
})
|
||||
|
||||
export const updateReservation = createServerFn({ method: 'POST' })
|
||||
.validator(updateReservationSchema)
|
||||
.validator(zodValidator(updateReservationSchema))
|
||||
.handler(async ({ data }) => {
|
||||
await requireStaffSession()
|
||||
|
||||
const settings = await loadReservationSettings()
|
||||
const reservationTime = new Date(data.reservationTime)
|
||||
|
||||
assertValidReservationSlot(
|
||||
reservationTime,
|
||||
settings.reservationIntervalMinutes,
|
||||
settings.minLeadTimeMinutes,
|
||||
settings.maxLeadTimeDays,
|
||||
settings.reservationDurationMinutes,
|
||||
)
|
||||
|
||||
const [table] = await db
|
||||
.select()
|
||||
.from(restaurantTables)
|
||||
@@ -112,16 +351,13 @@ export const updateReservation = createServerFn({ method: 'POST' })
|
||||
throw new Error(`${table.name} only seats up to ${table.seats} guests`)
|
||||
}
|
||||
|
||||
const [conflict] = await db
|
||||
.select()
|
||||
.from(reservations)
|
||||
.where(
|
||||
and(
|
||||
eq(reservations.tableId, data.tableId),
|
||||
eq(reservations.reservationTime, reservationTime),
|
||||
ne(reservations.id, data.id),
|
||||
),
|
||||
)
|
||||
const conflict = await findOverlappingReservation(
|
||||
data.tableId,
|
||||
reservationTime,
|
||||
settings.reservationDurationMinutes,
|
||||
settings.minLeadTimeMinutes,
|
||||
data.id,
|
||||
)
|
||||
|
||||
if (conflict) {
|
||||
throw new Error('This table is already booked for that time')
|
||||
@@ -133,8 +369,11 @@ export const updateReservation = createServerFn({ method: 'POST' })
|
||||
tableId: data.tableId,
|
||||
customerName: data.customerName,
|
||||
customerEmail: data.customerEmail,
|
||||
customerPhone: data.customerPhone,
|
||||
message: data.message,
|
||||
partySize: data.partySize,
|
||||
reservationTime,
|
||||
durationMinutes: settings.reservationDurationMinutes,
|
||||
})
|
||||
.where(eq(reservations.id, data.id))
|
||||
.returning()
|
||||
@@ -143,6 +382,49 @@ export const updateReservation = createServerFn({ method: 'POST' })
|
||||
throw new Error('Reservation not found')
|
||||
}
|
||||
|
||||
await sendReservationUpdateEmail({
|
||||
customerName: updated.customerName,
|
||||
customerEmail: updated.customerEmail,
|
||||
message: updated.message,
|
||||
partySize: updated.partySize,
|
||||
reservationTime: updated.reservationTime,
|
||||
cancelToken: updated.cancelToken,
|
||||
table,
|
||||
})
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
export const reservationStatusSchema = z.enum(['confirmed', 'no_show', 'completed'])
|
||||
|
||||
export type ReservationStatus = z.infer<typeof reservationStatusSchema>
|
||||
|
||||
export const RESERVATION_STATUS_LABELS: Record<ReservationStatus, string> = {
|
||||
confirmed: 'Bestätigt',
|
||||
no_show: 'Nicht erschienen',
|
||||
completed: 'Abgeschlossen',
|
||||
}
|
||||
|
||||
const updateReservationStatusSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
status: reservationStatusSchema,
|
||||
})
|
||||
|
||||
export const updateReservationStatus = createServerFn({ method: 'POST' })
|
||||
.validator(zodValidator(updateReservationStatusSchema))
|
||||
.handler(async ({ data }) => {
|
||||
await requireStaffSession()
|
||||
|
||||
const [updated] = await db
|
||||
.update(reservations)
|
||||
.set({ status: data.status })
|
||||
.where(eq(reservations.id, data.id))
|
||||
.returning()
|
||||
|
||||
if (!updated) {
|
||||
throw new Error('Reservation not found')
|
||||
}
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
@@ -151,7 +433,7 @@ const deleteReservationSchema = z.object({
|
||||
})
|
||||
|
||||
export const deleteReservation = createServerFn({ method: 'POST' })
|
||||
.validator(deleteReservationSchema)
|
||||
.validator(zodValidator(deleteReservationSchema))
|
||||
.handler(async ({ data }) => {
|
||||
await requireStaffSession()
|
||||
|
||||
@@ -161,5 +443,75 @@ export const deleteReservation = createServerFn({ method: 'POST' })
|
||||
throw new Error('Reservation not found')
|
||||
}
|
||||
|
||||
const [table] = await db
|
||||
.select()
|
||||
.from(restaurantTables)
|
||||
.where(eq(restaurantTables.id, deleted.tableId))
|
||||
|
||||
if (table) {
|
||||
await sendReservationCancellationEmail({
|
||||
customerName: deleted.customerName,
|
||||
customerEmail: deleted.customerEmail,
|
||||
partySize: deleted.partySize,
|
||||
reservationTime: deleted.reservationTime,
|
||||
cancelToken: deleted.cancelToken,
|
||||
table,
|
||||
})
|
||||
}
|
||||
|
||||
return deleted
|
||||
})
|
||||
|
||||
const getReservationByCancelTokenSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
})
|
||||
|
||||
export const getReservationByCancelToken = createServerFn({ method: 'GET' })
|
||||
.validator(zodValidator(getReservationByCancelTokenSchema))
|
||||
.handler(async ({ data }) => {
|
||||
const [reservation] = await db
|
||||
.select({
|
||||
customerName: reservations.customerName,
|
||||
partySize: reservations.partySize,
|
||||
reservationTime: reservations.reservationTime,
|
||||
tableName: restaurantTables.name,
|
||||
})
|
||||
.from(reservations)
|
||||
.innerJoin(restaurantTables, eq(reservations.tableId, restaurantTables.id))
|
||||
.where(eq(reservations.cancelToken, data.token))
|
||||
|
||||
return reservation ?? null
|
||||
})
|
||||
|
||||
const cancelReservationByTokenSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
})
|
||||
|
||||
export const cancelReservationByToken = createServerFn({ method: 'POST' })
|
||||
.validator(zodValidator(cancelReservationByTokenSchema))
|
||||
.handler(async ({ data }) => {
|
||||
const [deleted] = await db
|
||||
.delete(reservations)
|
||||
.where(eq(reservations.cancelToken, data.token))
|
||||
.returning()
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error('Reservation not found')
|
||||
}
|
||||
|
||||
const [table] = await db
|
||||
.select()
|
||||
.from(restaurantTables)
|
||||
.where(eq(restaurantTables.id, deleted.tableId))
|
||||
|
||||
if (table) {
|
||||
await sendReservationCancellationEmail({
|
||||
customerName: deleted.customerName,
|
||||
customerEmail: deleted.customerEmail,
|
||||
partySize: deleted.partySize,
|
||||
reservationTime: deleted.reservationTime,
|
||||
cancelToken: deleted.cancelToken,
|
||||
table,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { z, ZodType } from 'zod'
|
||||
|
||||
// `createServerFn().validator(schema)` passes a zod schema straight through to
|
||||
// TanStack Start, which on failure throws `new Error(JSON.stringify(issues))` —
|
||||
// the raw issue array, not a message meant for a user to read. Wrapping the
|
||||
// schema in a plain function (`.validator(zodValidator(schema))`) routes
|
||||
// validation through this instead, so a rejected field surfaces its own zod
|
||||
// `.message` (e.g. "Enter a valid phone number") rather than a JSON blob.
|
||||
export function zodValidator<Schema extends ZodType>(schema: Schema) {
|
||||
return (input: unknown): z.infer<Schema> => {
|
||||
const result = schema.safeParse(input)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.issues[0]?.message ?? 'Invalid input')
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createServerFn, createServerOnlyFn } from '@tanstack/react-start'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { db } from '#/db'
|
||||
import { restaurantSettings } from '#/db/schema'
|
||||
import { requireStaffSession } from '#/lib/auth'
|
||||
import { zodValidator } from '#/lib/server-validator'
|
||||
import { dataUrlImageSchema } from '#/lib/table-image'
|
||||
|
||||
// Reads never require a session — the public booking form needs the interval and
|
||||
// lead time to generate its own time-slot grid, just like getTables(). Exported
|
||||
// (rather than only reachable through the server fn below) so reservations.ts can
|
||||
// read the current settings directly while validating a booking. This module is
|
||||
// imported by client-reachable route files for the createServerFns below, so —
|
||||
// same issue as `#/lib/auth` — a plain, unwrapped export using `db` directly would
|
||||
// drag the Postgres driver into the browser bundle; `createServerOnlyFn` keeps it
|
||||
// server-side only.
|
||||
export const loadReservationSettings = createServerOnlyFn(async function loadReservationSettings() {
|
||||
const [existing] = await db.select().from(restaurantSettings).limit(1)
|
||||
if (existing) return existing
|
||||
|
||||
const [created] = await db.insert(restaurantSettings).values({}).returning()
|
||||
return created
|
||||
})
|
||||
|
||||
export const getReservationSettings = createServerFn({ method: 'GET' }).handler(async () => {
|
||||
return loadReservationSettings()
|
||||
})
|
||||
|
||||
const updateReservationSettingsSchema = z.object({
|
||||
reservationIntervalMinutes: z.number().int().min(5).max(120),
|
||||
reservationDurationMinutes: z.number().int().min(15).max(480),
|
||||
minLeadTimeMinutes: z.number().int().min(0).max(10080),
|
||||
maxLeadTimeDays: z.number().int().min(1).max(365),
|
||||
tableDisplayMode: z.enum(['individual', 'overview']),
|
||||
tableOverviewImageUrl: dataUrlImageSchema,
|
||||
})
|
||||
|
||||
export const updateReservationSettings = createServerFn({ method: 'POST' })
|
||||
.validator(zodValidator(updateReservationSettingsSchema))
|
||||
.handler(async ({ data }) => {
|
||||
console.log('[settings] updateReservationSettings called with', data)
|
||||
|
||||
const session = await requireStaffSession()
|
||||
console.log('[settings] session role:', session.user.role)
|
||||
|
||||
if (session.user.role !== 'admin') {
|
||||
throw new Error('Nur Admins dürfen die Einstellungen ändern')
|
||||
}
|
||||
|
||||
const existing = await loadReservationSettings()
|
||||
console.log('[settings] existing row before update:', existing)
|
||||
|
||||
const [updated] = await db
|
||||
.update(restaurantSettings)
|
||||
.set(data)
|
||||
.where(eq(restaurantSettings.id, existing.id))
|
||||
.returning()
|
||||
|
||||
console.log('[settings] row after update:', updated)
|
||||
|
||||
return updated
|
||||
})
|
||||
+5
-1
@@ -11,5 +11,9 @@ export const listStaffMembers = createServerFn({ method: 'GET' }).handler(async
|
||||
query: { sortBy: 'createdAt', sortDirection: 'asc', limit: 100 },
|
||||
})
|
||||
|
||||
return users
|
||||
// `listUsers`'s return type doesn't pick up `user.additionalFields` from the
|
||||
// auth config the way the actual response body does — `phone` is present at
|
||||
// runtime (Better Auth includes every configured additional field on every
|
||||
// user-returning endpoint) but missing from admin.d.ts's `UserWithRole` type.
|
||||
return users as Array<(typeof users)[number] & { phone: string | null }>
|
||||
})
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// Generous enough for a photo, small enough to keep rows (and the customer-facing
|
||||
// page that loads them up front) from bloating — these are data: URLs stored
|
||||
// inline, not uploads to disk-backed storage, which this app doesn't have.
|
||||
export const MAX_TABLE_IMAGE_BYTES = 2_000_000
|
||||
|
||||
export const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp']
|
||||
|
||||
export const dataUrlImageSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(Math.ceil((MAX_TABLE_IMAGE_BYTES * 4) / 3) + 100)
|
||||
.regex(/^data:image\/(png|jpeg|jpg|webp);base64,/, 'Bild muss als PNG, JPEG oder WebP hochgeladen werden')
|
||||
.nullable()
|
||||
|
||||
export function readImageAsDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Datei konnte nicht gelesen werden'))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user