feat: implement toast on contact form success, alerts for vacations, holidays and announcements

- Move address and contact methods to individual components.
main
RaviAnand Mohabir 3 months ago
parent 520dd137d8
commit 45d53a9617

@ -18,6 +18,51 @@ export default defineConfig({
// Files to exclude // Files to exclude
exclude: [], exclude: [],
patterns: {
extend: {
scrollable: {
description: "A container that allows for scrolling",
defaultValues: {
direction: "vertical",
hideScrollbar: false,
},
properties: {
// The direction of the scroll
direction: { type: "enum", value: ["horizontal", "vertical"] },
// Whether to hide the scrollbar
hideScrollbar: { type: "boolean" },
},
// disallow the `overflow` property (in TypeScript)
blocklist: ["overflow"],
transform(props) {
const { direction, hideScrollbar, ...rest } = props;
return {
overflow: "auto",
height: direction === "horizontal" ? "100%" : "auto",
width: direction === "vertical" ? "100%" : "auto",
scrollbarWidth: hideScrollbar ? "none" : "auto",
WebkitOverflowScrolling: "touch",
"&::-webkit-scrollbar": {
display: hideScrollbar ? "none" : "auto",
width: 2,
},
"&::-webkit-scrollbar-track": {
background: "{colors.gray.a8}",
},
"&::-webkit-scrollbar-thumb": {
background: "{colors.accent.a10}",
borderRadius: "md",
},
"&::-webkit-scrollbar-thumb:hover": {
background: "{colors.accent.10}",
},
...rest,
};
},
},
},
},
// Useful for theme customization // Useful for theme customization
theme: {}, theme: {},

@ -0,0 +1,45 @@
"use server";
import { getI18n, getLocalizedI18n } from "@/i18n/server";
import { contactSchema } from "@/types/contact";
import { getPayload } from "@/utils/payload";
import { getSettings } from "@/api";
import { renderContactConfirmationEmail } from "@/emails/contact-confirmation";
import { renderContactEmail } from "@/emails/contact";
export const submitContactFormAction = async (_: any, formData: FormData) => {
const { success, error, data } = contactSchema.safeParse(Object.fromEntries(formData));
if (!success) {
return { success, error: error.flatten().fieldErrors };
}
const payload = await getPayload();
const { adminLanguage, contactEmailsTo } = await getSettings();
const [t, reset] = await getLocalizedI18n(adminLanguage);
try {
let res = await payload.sendEmail({
to: contactEmailsTo,
subject: t("email.contactSubject", { name: data.name }),
email: await renderContactEmail({
t,
...data,
}),
});
res = await payload.sendEmail({
to: data.email,
subject: t("email.contactConfirmationSubject"),
email: await renderContactConfirmationEmail({
t,
...data,
}),
});
} finally {
reset();
}
return { success: true };
};

@ -1,53 +0,0 @@
"use server";
import { getI18n } from "@/i18n/server";
import { getPayload } from "@/utils/payload";
import { getSettings } from "@/api";
import { renderContactConfirmationEmail } from "@/emails/contact-confirmation";
import { renderContactEmail } from "@/emails/contact";
export const submitContactFormAction = async (formData: FormData) => {
const payload = await getPayload();
const { adminLanguage, contactEmailsTo } = await getSettings();
const t = await getI18n();
console.log(
await renderContactEmail(
{
name: formData.get("name"),
email: formData.get("email"),
subject: formData.get("subject"),
message: formData.get("message"),
locale: adminLanguage,
},
{ plainText: true },
),
);
await payload.sendEmail({
to: contactEmailsTo,
subject: t("email.contactSubject", { name: formData.get("name") }),
email: await renderContactEmail(
{
name: formData.get("name"),
email: formData.get("email"),
subject: formData.get("subject"),
message: formData.get("message"),
locale: adminLanguage,
},
{ plainText: true },
),
});
await payload.sendEmail({
to: formData.get("email"),
subject: `Bestätigung Ihrer Kontaktanfrage ${formData.get("name")}`,
email: await renderContactConfirmationEmail({
name: formData.get("name"),
email: formData.get("email"),
subject: formData.get("subject"),
message: formData.get("message"),
locale: adminLanguage,
}),
});
};

@ -0,0 +1,23 @@
import type { Options } from "node_modules/payload/dist/collections/operations/local/find";
import { getPayload } from "@/utils/payload";
export const getCurrentAnnouncements = async (
opts: Omit<Options<"announcement">, "collection" | "sort" | "pagination">,
) => {
const payload = await getPayload();
const today = new Date();
return await payload.find({
collection: "announcement",
sort: "from",
where: {
or: [
{ from: { equals: today } },
{ and: [{ from: { less_than_equal: today } }, { to: { greater_than_equal: today } }] },
],
},
pagination: false,
...opts,
});
};

@ -0,0 +1,23 @@
import type { Options } from "node_modules/payload/dist/collections/operations/local/find";
import { getPayload } from "@/utils/payload";
export const getCurrentHolidays = async (
opts: Omit<Options<"holiday">, "collection" | "sort" | "pagination">,
) => {
const payload = await getPayload();
const today = new Date();
return await payload.find({
collection: "holiday",
sort: "from",
where: {
or: [
{ from: { equals: today } },
{ and: [{ from: { less_than_equal: today } }, { to: { greater_than_equal: today } }] },
],
},
pagination: false,
...opts,
});
};

@ -1,7 +1,9 @@
export * from "./about"; export * from "./about";
export * from "./announcements";
export * from "./contact"; export * from "./contact";
export * from "./gallery"; export * from "./gallery";
export * from "./holidays";
export * from "./home"; export * from "./home";
export * from "./menu"; export * from "./menu";
export * from "./settings"; export * from "./settings";
export * from "./vacations";

@ -0,0 +1,23 @@
import type { Options } from "node_modules/payload/dist/collections/operations/local/find";
import { getPayload } from "@/utils/payload";
export const getCurrentVacations = async (
opts: Omit<Options<"vacation">, "collection" | "sort" | "pagination">,
) => {
const payload = await getPayload();
const today = new Date();
return await payload.find({
collection: "vacation",
sort: "from",
where: {
or: [
{ from: { equals: today } },
{ and: [{ from: { less_than_equal: today } }, { to: { greater_than_equal: today } }] },
],
},
pagination: false,
...opts,
});
};

@ -1,37 +1,64 @@
import { HStack, styled } from "@styled-system/jsx"; "use client";
import { HStack, Stack, styled } from "@styled-system/jsx";
import { useActionState, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Field } from "@/components/ui/field"; import { Field } from "@/components/ui/field";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { getI18n } from "@/i18n/server"; import { Text } from "@/components/ui/text";
import { stack } from "@styled-system/patterns"; import { stack } from "@styled-system/patterns";
import { submitContactFormAction } from "@/actions/contact"; import { submitContactFormAction } from "@/actions/contact";
import { useI18n } from "@/i18n/client";
import { useToast } from "@/hooks/toast";
const hasError = (error: string[] | undefined) => error !== undefined && error.length > 0;
export default function ContactForm() {
const [state, formAction, pending] = useActionState(submitContactFormAction, null);
const t = useI18n();
const toaster = useToast();
export default async function ContactForm() { useEffect(() => {
const t = await getI18n(); if (state?.success) {
toaster.create({
title: t("contact.messageSent"),
description: t("contact.messageSentDescription"),
});
}
}, [state, toaster]);
return ( return (
<styled.form action={submitContactFormAction} className={stack()}> <styled.form action={formAction} className={stack()}>
<Field.Root id="name"> <Field.Root id="name" invalid={hasError(state?.error?.name)}>
<Field.Label>{t("contact.name")}</Field.Label> <Field.Label>{t("contact.name")}</Field.Label>
<Field.Input name="name" placeholder={t("contact.name")} /> <Field.Input name="name" placeholder={t("contact.name")} />
<Field.ErrorText>{state?.error?.name?.[0]}</Field.ErrorText>
</Field.Root> </Field.Root>
<Field.Root id="email"> <Field.Root id="email" invalid={hasError(state?.error?.email)}>
<Field.Label>{t("general.email")}</Field.Label> <Field.Label>{t("general.email")}</Field.Label>
<Field.Input name="email" placeholder={t("general.email")} asChild> <Field.Input name="email" placeholder={t("general.email")} asChild>
<Input type="email" /> <Input type="email" />
</Field.Input> </Field.Input>
<Field.ErrorText>{state?.error?.email?.[0]}</Field.ErrorText>
</Field.Root> </Field.Root>
<Field.Root id="subject"> <Field.Root id="subject" invalid={hasError(state?.error?.subject)}>
<Field.Label>{t("contact.subject")}</Field.Label> <Field.Label>{t("contact.subject")}</Field.Label>
<Field.Input name="subject" placeholder={t("contact.subject")} /> <Field.Input name="subject" placeholder={t("contact.subject")} />
<Field.ErrorText>{state?.error?.subject?.[0]}</Field.ErrorText>
</Field.Root> </Field.Root>
<Field.Root id="message"> <Field.Root id="message" invalid={hasError(state?.error?.message)}>
<Field.Label>{t("contact.message")}</Field.Label> <Field.Label>{t("contact.message")}</Field.Label>
<Field.Textarea name="message" placeholder={t("contact.message")}/> <Field.Textarea name="message" placeholder={t("contact.message")} />
<Field.ErrorText>{state?.error?.message?.[0]}</Field.ErrorText>
</Field.Root> </Field.Root>
<HStack justify="end"> <HStack justify="end">
<Button type="submit">{t("general.submit")}</Button> <Stack>
<Button type="submit" loading={pending}>
{t("general.submit")}
</Button>
{state?.success && <Text color="green">Nachricht gesendet</Text>}
</Stack>
</HStack> </HStack>
</styled.form> </styled.form>
); );

@ -1,21 +1,30 @@
import { Box, HStack, Stack } from "@styled-system/jsx"; import { Box, Grid, HStack, Stack } from "@styled-system/jsx";
import { getAbout, getContact } from "@/api"; import { getAbout, getContact } from "@/api";
import Address from "@/components/general/address";
import ContactForm from "./contact-form"; import ContactForm from "./contact-form";
import ContactMethods from "@/components/general/contact-methods";
import { Heading } from "@/components/ui/heading"; import { Heading } from "@/components/ui/heading";
import { Link } from "@/components/ui/link";
import OpeningTimes from "@/components/general/opening-times";
import { Params } from "../shared"; import { Params } from "../shared";
import ReservationForm from "./reservation-form"; import ReservationForm from "./reservation-form";
import { Tabs } from "@/components/ui/tabs"; import { Tabs } from "@/components/ui/tabs";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { getI18n } from "@/i18n/server"; import { getI18n } from "@/i18n/server";
import { getOpeningTimes } from "@/api/openingTimes";
import { styled } from "@styled-system/jsx"; import { styled } from "@styled-system/jsx";
export default async function Contact({ params: { locale } }: { params: Params }) { export default async function Contact({
params: { locale },
searchParams: { tab = "contact" },
}: {
params: Params;
searchParams: { tab?: "reservation" | "contact" };
}) {
console.log(tab);
const t = await getI18n(); const t = await getI18n();
const about = await getAbout({ locale }); const about = await getAbout({ locale });
const contact = await getContact({ locale }); const contact = await getContact({ locale });
const openingTimes = await getOpeningTimes({ locale });
return ( return (
<Stack p={6} gap={6}> <Stack p={6} gap={6}>
@ -24,80 +33,42 @@ export default async function Contact({ params: { locale } }: { params: Params }
</Heading> </Heading>
<HStack gap={10} flexWrap="wrap" justifyContent="space-between" alignItems="start"> <HStack gap={10} flexWrap="wrap" justifyContent="space-between" alignItems="start">
<Tabs.Root <Tabs.Root defaultValue={tab} orientation="horizontal" minW={350} flex={1} flexGrow={1}>
defaultValue="reservation"
orientation="horizontal"
minW={350}
flex={1}
flexGrow={1}
>
<Tabs.List> <Tabs.List>
<Tabs.Trigger value="reservation">{t("contact.reservation")}</Tabs.Trigger>
<Tabs.Trigger value="contact">{t("general.contact")}</Tabs.Trigger> <Tabs.Trigger value="contact">{t("general.contact")}</Tabs.Trigger>
<Tabs.Trigger value="reservation">{t("contact.reservation")}</Tabs.Trigger>
<Tabs.Indicator /> <Tabs.Indicator />
</Tabs.List> </Tabs.List>
<Tabs.Content value="reservation">
<ReservationForm />
</Tabs.Content>
<Tabs.Content value="contact"> <Tabs.Content value="contact">
<ContactForm /> <ContactForm />
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="reservation">
<ReservationForm />
</Tabs.Content>
</Tabs.Root> </Tabs.Root>
<Stack gap={6}> <Stack gap={6} flex={1} flexGrow={1}>
<Stack> <Stack>
<Heading as="h3" size="xl"> <Heading as="h3" size="xl">
{t("general.openingTimes")} {t("general.openingTimes")}
</Heading> </Heading>
{openingTimes.docs.map((ot) => ( <OpeningTimes locale={locale} />
<HStack key={ot.id}>
<Text w={250}>
{t(`days.${ot.from}`)}
{ot.to ? `- ${t(`days.${ot.to}`)}` : ""}
</Text>
<Text>
{new Date(ot.timeOpen).toLocaleTimeString("de-CH", { timeStyle: "short" })} -{" "}
{new Date(ot.timeClose).toLocaleTimeString("de-CH", { timeStyle: "short" })}
</Text>
</HStack>
))}
</Stack> </Stack>
<Stack> <Stack>
<Heading as="h3" size="xl"> <Heading as="h3" size="xl">
{t("general.address")} {t("general.address")}
</Heading> </Heading>
<HStack gap={20} flexWrap="wrap"> <HStack gap={12} alignItems="start">
<Address locale={locale} />
<ContactMethods locale={locale} />
</HStack>
{contact.address.embeddedMaps && ( {contact.address.embeddedMaps && (
<styled.div <styled.div
dangerouslySetInnerHTML={{ __html: contact.address.embeddedMaps }} dangerouslySetInnerHTML={{ __html: contact.address.embeddedMaps }}
width={600} width={600}
/> />
)} )}
<Stack>
<Box>
<Text fontWeight="bold">{about.name}</Text>
<Text>
{contact.address.street} {contact.address.number}
</Text>
<Text>
{contact.address.zip} {contact.address.area}
</Text>
</Box>
{contact.phone && (
<HStack>
<Text fontWeight="bold">{t("general.phoneNumber")}</Text>
<styled.a href={`tel:${contact.phone}`}>{contact.phone}</styled.a>
</HStack>
)}
{contact.email && (
<HStack>
<Text fontWeight="bold">{t("general.email")}</Text>
<styled.a href={`mailto:${contact.email}`}>{contact.email}</styled.a>
</HStack>
)}
</Stack>
</HStack>
</Stack> </Stack>
</Stack> </Stack>
</HStack> </HStack>

@ -7,6 +7,7 @@ import { I18nProviderClient } from "@/i18n/client";
import { Metadata } from "next"; import { Metadata } from "next";
import Navbar from "@/components/layout/navbar"; import Navbar from "@/components/layout/navbar";
import { Params } from "./shared"; import { Params } from "./shared";
import { ToastContextProvider } from "@/contexts/toast";
import { getAbout } from "@/api"; import { getAbout } from "@/api";
import localFont from "next/font/local"; import localFont from "next/font/local";
import { styled } from "@styled-system/jsx"; import { styled } from "@styled-system/jsx";
@ -26,6 +27,7 @@ export default async function RootLayout({
return ( return (
<html lang={locale}> <html lang={locale}>
<I18nProviderClient locale={locale}> <I18nProviderClient locale={locale}>
<ToastContextProvider>
<styled.body className={moderustic.className}> <styled.body className={moderustic.className}>
<Navbar locale={locale} /> <Navbar locale={locale} />
<styled.main mt={20} pb={20}> <styled.main mt={20} pb={20}>
@ -33,6 +35,7 @@ export default async function RootLayout({
</styled.main> </styled.main>
<Footer locale={locale} /> <Footer locale={locale} />
</styled.body> </styled.body>
</ToastContextProvider>
</I18nProviderClient> </I18nProviderClient>
</html> </html>
); );

@ -1,17 +1,33 @@
import { Box, Container, Stack } from "@styled-system/jsx"; import { Box, Container, Stack, styled } from "@styled-system/jsx";
import { getAbout, getHome } from "@/api"; import { Info, Palmtree, PartyPopper } from "lucide-react";
import {
getAbout,
getCurrentAnnouncements,
getCurrentHolidays,
getCurrentVacations,
getHome,
} from "@/api";
import { Alert } from "@/components/ui/alert";
import Gallery from "./gallery"; import Gallery from "./gallery";
import { Heading } from "@/components/ui/heading";
import Image from "next/image"; import Image from "next/image";
import { Media } from "@/payload-types"; import { Media } from "@/payload-types";
import { Params } from "./shared"; import { Params } from "./shared";
import RichText from "@/components/rich-text"; import RichText from "@/components/rich-text";
import { Text } from "@/components/ui/text";
import { css } from "@styled-system/css"; import { css } from "@styled-system/css";
import { styled } from "@styled-system/jsx"; import { getI18n } from "@/i18n/server";
import { scrollable } from "@styled-system/patterns";
export default async function Home({ params: { locale } }: { params: Params }) { export default async function Home({ params: { locale } }: { params: Params }) {
const t = await getI18n();
const home = await getHome({ locale }); const home = await getHome({ locale });
const about = await getAbout({ locale }); const about = await getAbout({ locale });
const vacations = await getCurrentVacations({ locale });
const holidays = await getCurrentHolidays({ locale });
const announcements = await getCurrentAnnouncements({ locale });
return ( return (
<Stack gap={10} align="center"> <Stack gap={10} align="center">
@ -22,16 +38,65 @@ export default async function Home({ params: { locale } }: { params: Params }) {
className={css({ objectFit: "cover" })} className={css({ objectFit: "cover" })}
fill fill
/> />
<styled.div <Stack
position="absolute" position="absolute"
top="10%"
width="100%"
maxH="80%"
align="center"
px={30}
gap={24}
color="white" color="white"
top="60%" >
<Stack
maxW={550}
width="100%" width="100%"
textAlign="center" className={scrollable({ hideScrollbar: false, px: 2 })}
fontSize={36}
> >
{announcements.docs.length > 0 && (
<Heading size="xl" color="white">
{t("general.announcements")}
</Heading>
)}
{announcements.docs.map((v) => (
<Alert.Root bg="white.a10">
<Alert.Icon asChild>
<Info />
</Alert.Icon>
<Alert.Title>{v.title}</Alert.Title>
</Alert.Root>
))}
{holidays.docs.length > 0 && (
<Heading size="xl" mt={4} color="white">
{t("general.holidays")}
</Heading>
)}
{holidays.docs.map((v) => (
<Alert.Root bg="white.a10">
<Alert.Icon asChild>
<PartyPopper />
</Alert.Icon>
<Alert.Title>{v.title}</Alert.Title>
</Alert.Root>
))}
{vacations.docs.length > 0 && (
<Heading size="xl" mt={4} color="white">
{t("general.vacations")}
</Heading>
)}
{vacations.docs.map((v) => (
<Alert.Root bg="white.a10">
<Alert.Icon asChild>
<Palmtree />
</Alert.Icon>
<Alert.Title>{v.title}</Alert.Title>
</Alert.Root>
))}
</Stack>
<Text color="white" maxW={550} width="100%" textAlign="center" fontSize={36}>
{home.tagline} {home.tagline}
</styled.div> </Text>
</Stack>
</Box> </Box>
<Container> <Container>
<RichText content={home.aboutText} /> <RichText content={home.aboutText} />

@ -13,6 +13,7 @@ export const Announcement: CollectionConfig = {
name: "title", name: "title",
type: "text", type: "text",
localized: true, localized: true,
required: true,
}, },
{ {
name: "description", name: "description",
@ -28,6 +29,7 @@ export const Announcement: CollectionConfig = {
displayFormat: "d MMM yyy", displayFormat: "d MMM yyy",
}, },
}, },
required: true,
}, },
{ {
name: "to", name: "to",

@ -13,6 +13,7 @@ export const Holiday: CollectionConfig = {
name: "title", name: "title",
type: "text", type: "text",
localized: true, localized: true,
required: true,
}, },
{ {
name: "from", name: "from",
@ -23,6 +24,7 @@ export const Holiday: CollectionConfig = {
displayFormat: "d MMM yyy", displayFormat: "d MMM yyy",
}, },
}, },
required: true,
}, },
{ {
name: "to", name: "to",

@ -13,6 +13,7 @@ export const Vacation: CollectionConfig = {
name: "title", name: "title",
type: "text", type: "text",
localized: true, localized: true,
required: true,
}, },
{ {
name: "from", name: "from",
@ -23,6 +24,7 @@ export const Vacation: CollectionConfig = {
displayFormat: "d MMM yyy", displayFormat: "d MMM yyy",
}, },
}, },
required: true,
}, },
{ {
name: "to", name: "to",

@ -0,0 +1,22 @@
import { getAbout, getContact } from "@/api";
import { Box } from "@styled-system/jsx";
import { Locale } from "@/i18n/settings";
import { Text } from "@/components/ui/text";
export default async function Address({ locale }: { locale: Locale }) {
const about = await getAbout({ locale });
const contact = await getContact({ locale });
return (
<Box>
<Text fontWeight="bold">{about.name}</Text>
<Text>
{contact.address.street} {contact.address.number}
</Text>
<Text>
{contact.address.zip} {contact.address.area}
</Text>
</Box>
);
}

@ -0,0 +1,28 @@
import { Grid } from "@styled-system/jsx";
import { Link } from "@/components/ui/link";
import { Locale } from "@/i18n/settings";
import { Text } from "@/components/ui/text";
import { getContact } from "@/api";
import { getI18n } from "@/i18n/server";
export default async function ContactMethods({ locale }: { locale: Locale }) {
const t = await getI18n();
const contact = await getContact({ locale });
return (
<Grid gridTemplateColumns="min-content 1fr" columnGap={2} rowGap={0.5}>
{contact.phone && (
<>
<Text fontWeight="bold">{t("general.phoneNumber")}</Text>
<Link href={`tel:${contact.phone}`}>{contact.phone}</Link>
</>
)}
{contact.email && (
<>
<Text fontWeight="bold">{t("general.email")}</Text>
<Link href={`mailto:${contact.email}`}>{contact.email}</Link>
</>
)}
</Grid>
);
}

@ -0,0 +1,28 @@
import { Box, HStack } from "@styled-system/jsx";
import { Locale } from "@/i18n/settings";
import { Text } from "@/components/ui/text";
import { getI18n } from "@/i18n/server";
import { getOpeningTimes } from "@/api/openingTimes";
export default async function OpeningTimes({ locale }: { locale: Locale }) {
const t = await getI18n();
const openingTimes = await getOpeningTimes({ locale });
return (
<Box>
{openingTimes.docs.map((ot) => (
<HStack key={ot.id}>
<Text w={250}>
{t(`days.${ot.from}`)}
{ot.to ? ` - ${t(`days.${ot.to}`)}` : ""}
</Text>
<Text>
{new Date(ot.timeOpen).toLocaleTimeString("de-CH", { timeStyle: "short" })} -{" "}
{new Date(ot.timeClose).toLocaleTimeString("de-CH", { timeStyle: "short" })}
</Text>
</HStack>
))}
</Box>
);
}

@ -1,28 +1,25 @@
import { Box, HStack, Stack, styled } from "@styled-system/jsx"; import { HStack, Stack, styled } from "@styled-system/jsx";
import { getAbout, getContact } from "@/api";
import Address from "@/components/general/address";
import ContactMethods from "@/components/general/contact-methods";
import { Heading } from "@/components/ui/heading";
import { IconButton } from "@/components/ui/icon-button"; import { IconButton } from "@/components/ui/icon-button";
import { Locale } from "@/i18n/settings"; import { Locale } from "@/i18n/settings";
import OpeningTimes from "@/components/general/opening-times";
import { SiFacebook } from "@icons-pack/react-simple-icons"; import { SiFacebook } from "@icons-pack/react-simple-icons";
import { Text } from "@/components/ui/text"; import { Text } from "@/components/ui/text";
import { getContact } from "@/api";
import { getI18n } from "@/i18n/server";
import { stack } from "@styled-system/patterns"; import { stack } from "@styled-system/patterns";
export default async function Footer({ locale }: { locale: Locale }) { export default async function Footer({ locale }: { locale: Locale }) {
const about = await getAbout({ locale });
const contact = await getContact({ locale }); const contact = await getContact({ locale });
const t = await getI18n();
return ( return (
<styled.footer h={60} p={8} className={stack({ gap: 4, justify: "end" })}> <styled.footer minH={60} p={8} className={stack({ gap: 4, justify: "end" })}>
<HStack justify="space-between" alignItems="start"> <HStack justify="space-between" alignItems="start" flexWrap="wrap">
<Box> <Address locale={locale} />
<Text fontWeight="bold">{about.name}</Text>
<Text>
{contact.address.street} {contact.address.number}
</Text>
<Text>
{contact.address.zip} {contact.address.area}
</Text>
</Box>
<HStack> <HStack>
{(contact.socialLinks ?? []).map((sl) => ( {(contact.socialLinks ?? []).map((sl) => (
<IconButton key={sl.id} asChild variant="link"> <IconButton key={sl.id} asChild variant="link">
@ -32,11 +29,12 @@ export default async function Footer({ locale }: { locale: Locale }) {
</IconButton> </IconButton>
))} ))}
</HStack> </HStack>
<Stack gap={2}> <ContactMethods locale={locale} />
{contact.phone && <styled.a href={`tel:${contact.phone}`}>{contact.phone}</styled.a>}
{contact.email && <styled.a href={`mailto:${contact.email}`}>{contact.email}</styled.a>}
</Stack>
</HStack> </HStack>
<Stack alignSelf="center" gap={1}>
<Heading size="lg">{t("general.openingTimes")}</Heading>
<OpeningTimes locale={locale} />
</Stack>
<Text textAlign="center">Powered by Jenyus</Text> <Text textAlign="center">Powered by Jenyus</Text>
</styled.footer> </styled.footer>
); );

@ -62,13 +62,14 @@ export default function MobileNav({ about }: { about: About }) {
<NavLink href="/">{t("general.home")}</NavLink> <NavLink href="/">{t("general.home")}</NavLink>
<NavLink href="/about">{t("general.about")}</NavLink> <NavLink href="/about">{t("general.about")}</NavLink>
<NavLink href="/menu">{t("general.menu")}</NavLink> <NavLink href="/menu">{t("general.menu")}</NavLink>
<NavLink href="/contact">{t("general.contact")}</NavLink>
<NavLink <NavLink
href="/contact" href="/contact?tab=reservation"
type="button" type="button"
className={css({ alignSelf: "stretch", textAlign: "center", marginTop: "auto" })} className={css({ alignSelf: "stretch", textAlign: "center", marginTop: "auto" })}
> >
{t("general.contact")} {t("contact.reservation")}
</NavLink> </NavLink>
</styled.div> </styled.div>
</styled.div> </styled.div>

@ -52,13 +52,16 @@ export default async function Navbar({ locale }: { locale: Locale }) {
<NavLink href="/menu" className={css({ display: "none", sm: { display: "block" } })}> <NavLink href="/menu" className={css({ display: "none", sm: { display: "block" } })}>
{t("general.menu")} {t("general.menu")}
</NavLink> </NavLink>
<NavLink href="/contact" className={css({ display: "none", sm: { display: "block" } })}>
{t("general.contact")}
</NavLink>
<NavLink <NavLink
href="/contact" href="/contact?tab=reservation"
type="button" type="button"
className={css({ display: "none", sm: { display: "block" } })} className={css({ display: "none", sm: { display: "block" } })}
> >
{t("general.contact")} {t("contact.reservation")}
</NavLink> </NavLink>
<LanguagePicker /> <LanguagePicker />

@ -0,0 +1 @@
export * as Alert from './styled/alert'

@ -0,0 +1 @@
export { Link, type LinkProps } from './styled/link'

@ -0,0 +1,34 @@
'use client'
import type { Assign, PolymorphicProps } from '@ark-ui/react'
import { ark } from '@ark-ui/react/factory'
import { alert } from 'styled-system/recipes'
import type { ComponentProps, HTMLStyledProps } from 'styled-system/types'
import { createStyleContext } from './utils/create-style-context'
const { withProvider, withContext } = createStyleContext(alert)
export type RootProps = ComponentProps<typeof Root>
export const Root = withProvider<HTMLDivElement, Assign<HTMLStyledProps<'div'>, PolymorphicProps>>(
ark.div,
'root',
)
export const Content = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, PolymorphicProps>
>(ark.div, 'content')
export const Description = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, PolymorphicProps>
>(ark.div, 'description')
export const Icon = withContext<HTMLOrSVGElement, Assign<HTMLStyledProps<'svg'>, PolymorphicProps>>(
ark.svg,
'icon',
)
export const Title = withContext<
HTMLHeadingElement,
Assign<HTMLStyledProps<'h5'>, PolymorphicProps>
>(ark.h5, 'title')

@ -0,0 +1,7 @@
import { ark } from '@ark-ui/react/factory'
import { styled } from 'styled-system/jsx'
import { link } from 'styled-system/recipes'
import type { ComponentProps } from 'styled-system/types'
export type LinkProps = ComponentProps<typeof Link>
export const Link = styled(ark.a, link)

@ -0,0 +1,41 @@
'use client'
import type { Assign } from '@ark-ui/react'
import { Toast } from '@ark-ui/react/toast'
import { toast } from 'styled-system/recipes'
import type { HTMLStyledProps } from 'styled-system/types'
import { createStyleContext } from './utils/create-style-context'
const { withProvider, withContext } = createStyleContext(toast)
export const Root = withProvider<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, Toast.ActionTriggerProps>
>(Toast.Root, 'root')
export const ActionTrigger = withContext<
HTMLButtonElement,
Assign<HTMLStyledProps<'button'>, Toast.ActionTriggerProps>
>(Toast.ActionTrigger, 'actionTrigger')
export const CloseTrigger = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, Toast.CloseTriggerProps>
>(Toast.CloseTrigger, 'closeTrigger')
export const Description = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, Toast.DescriptionProps>
>(Toast.Description, 'description')
export const Title = withContext<HTMLDivElement, Assign<HTMLStyledProps<'div'>, Toast.TitleProps>>(
Toast.Title,
'title',
)
export {
ToastContext as Context,
Toaster,
createToaster,
type ToastContextProps as ContextProps,
type ToasterProps,
} from '@ark-ui/react/toast'

@ -0,0 +1 @@
export * as Toast from './styled/toast'

@ -0,0 +1,38 @@
"use client";
import React, { ReactNode, useContext } from "react";
import { IconButton } from "@/components/ui/icon-button";
import { Toast } from "@/components/ui/toast";
import { XIcon } from "lucide-react";
const toaster = Toast.createToaster({
placement: "bottom-end",
overlap: true,
gap: 16,
});
export const ToastContext = React.createContext({ toaster });
export function ToastContextProvider({ children }: { children: ReactNode }) {
return (
<>
{children}
<ToastContext.Provider value={{ toaster }}>
<Toast.Toaster toaster={toaster}>
{(toast) => (
<Toast.Root key={toast.id}>
<Toast.Title>{toast.title}</Toast.Title>
<Toast.Description>{toast.description}</Toast.Description>
<Toast.CloseTrigger asChild>
<IconButton size="sm" variant="link">
<XIcon />
</IconButton>
</Toast.CloseTrigger>
</Toast.Root>
)}
</Toast.Toaster>
</ToastContext.Provider>
</>
);
}

@ -1,19 +1,19 @@
import { Font, Head, Html, Preview, Tailwind, Text } from "@react-email/components"; import { Font, Head, Html, Preview, Tailwind, Text } from "@react-email/components";
import { I18nProviderClient, useI18n } from "@/i18n/client"; import { i18nProviderWrapper, renderEmail } from "@/utils/email";
import { Options, render } from "@react-email/render";
import { defaultLocale } from "@/i18n/settings"; import { getI18n } from "@/i18n/server";
type ContactConfirmationEmailContentProps = { type T = Awaited<ReturnType<typeof getI18n>>;
export type ContactConfirmationEmailProps = {
name: string; name: string;
email: string; email: string;
subject: string; subject: string;
message: string; message: string;
t: T;
}; };
function ContactConfirmationEmailContent(_props: ContactConfirmationEmailContentProps) { export function ContactConfirmationEmail({ t }: ContactConfirmationEmailProps) {
const t = useI18n();
return ( return (
<Html> <Html>
<Head> <Head>
@ -37,24 +37,6 @@ function ContactConfirmationEmailContent(_props: ContactConfirmationEmailContent
); );
} }
type ContactConfirmationEmailProps = { export const renderContactConfirmationEmail = renderEmail(ContactConfirmationEmail);
locale: string;
} & ContactConfirmationEmailContentProps;
export default function ContactConfirmationEmail({ export default i18nProviderWrapper(ContactConfirmationEmail);
locale = defaultLocale,
...props
}: ContactConfirmationEmailProps) {
return (
<I18nProviderClient locale={locale}>
<ContactConfirmationEmailContent {...props} />
</I18nProviderClient>
);
}
export async function renderContactConfirmationEmail(
props: ContactConfirmationEmailProps,
opts?: Options,
) {
return await render(<ContactConfirmationEmail {...props} />, opts);
}

@ -9,21 +9,27 @@ import {
Tailwind, Tailwind,
Text, Text,
} from "@react-email/components"; } from "@react-email/components";
import { I18nProviderClient, useI18n } from "@/i18n/client"; import { i18nProviderWrapper, renderEmail } from "@/utils/email";
import { Options, render } from "@react-email/render";
import { defaultLocale } from "@/i18n/settings"; import { getI18n } from "@/i18n/server";
type ContactEmailContentProps = { name: string; email: string; subject: string; message: string }; type T = Awaited<ReturnType<typeof getI18n>>;
function ContactEmailContent({ export type ContactEmailProps = {
name: string;
email: string;
subject: string;
message: string;
t: T;
};
export function ContactEmail({
name = "[[Name]]", name = "[[Name]]",
subject = "[[Subject]]", subject = "[[Subject]]",
email = "[[Email]]", email = "[[Email]]",
message = "[[Message]]", message = "[[Message]]",
}: ContactEmailContentProps) { t,
const t = useI18n(); }: ContactEmailProps) {
return ( return (
<Html> <Html>
<Head> <Head>
@ -60,16 +66,6 @@ function ContactEmailContent({
); );
} }
type ContactEmailProps = { locale: string } & ContactEmailContentProps; export const renderContactEmail = renderEmail(ContactEmail);
export default function ContactEmail({ locale = defaultLocale, ...props }: ContactEmailProps) { export default i18nProviderWrapper(ContactEmail);
return (
<I18nProviderClient locale={locale}>
<ContactEmailContent {...props} />
</I18nProviderClient>
);
}
export async function renderContactEmail(props: ContactEmailProps, opts?: Options) {
return await render(<ContactEmail {...props} />, opts);
}

@ -13,10 +13,12 @@ export const Settings: GlobalConfig = {
value: l.code, value: l.code,
label: l.label[l.code] ?? l.label[defaultLocale], label: l.label[l.code] ?? l.label[defaultLocale],
})), })),
required: true,
}, },
{ {
name: "contactEmailsTo", name: "contactEmailsTo",
type: "email", type: "email",
required: true,
}, },
], ],
}; };

@ -0,0 +1,6 @@
import { ToastContext } from "@/contexts/toast";
import { useContext } from "react";
export function useToast() {
return useContext(ToastContext).toaster;
}

@ -15,6 +15,9 @@ export default {
de: "Deutsch", de: "Deutsch",
fr: "Französisch", fr: "Französisch",
it: "Italienisch", it: "Italienisch",
announcements: "Ankündigungen",
holidays: "Feiertage",
vacations: "Ferien",
}, },
days: { days: {
"0": "Montag", "0": "Montag",

@ -15,6 +15,9 @@ export default {
de: "German", de: "German",
fr: "French", fr: "French",
it: "Italian", it: "Italian",
announcements: "Announcements",
holidays: "Holidays",
vacations: "Vacations",
}, },
days: { days: {
"0": "Monday", "0": "Monday",

@ -1,5 +1,13 @@
import { createI18nServer } from "next-international/server"; import { Locale, importedLocales } from "./settings";
import { importedLocales } from "./settings"; import { createI18nServer, setStaticParamsLocale } from "next-international/server";
export const { getI18n, getScopedI18n, getStaticParams, getCurrentLocale } = export const { getI18n, getScopedI18n, getStaticParams, getCurrentLocale } =
createI18nServer(importedLocales); createI18nServer(importedLocales);
export const getLocalizedI18n = async (locale: Locale) => {
const prevLocale = getCurrentLocale();
setStaticParamsLocale(locale);
return [await getI18n(), () => setStaticParamsLocale(prevLocale)] as const;
};

@ -18,6 +18,7 @@ export interface Config {
'menu-category': MenuCategory; 'menu-category': MenuCategory;
'menu-item-tag': MenuItemTag; 'menu-item-tag': MenuItemTag;
'food-declaration': FoodDeclaration; 'food-declaration': FoodDeclaration;
announcement: Announcement;
vacation: Vacation; vacation: Vacation;
holiday: Holiday; holiday: Holiday;
'payload-preferences': PayloadPreference; 'payload-preferences': PayloadPreference;
@ -199,14 +200,41 @@ export interface FoodDeclaration {
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "announcement".
*/
export interface Announcement {
id: string;
title: string;
description?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
from: string;
to?: string | null;
updatedAt: string;
createdAt: string;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "vacation". * via the `definition` "vacation".
*/ */
export interface Vacation { export interface Vacation {
id: string; id: string;
title?: string | null; title: string;
from?: string | null; from: string;
to?: string | null; to?: string | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@ -217,8 +245,8 @@ export interface Vacation {
*/ */
export interface Holiday { export interface Holiday {
id: string; id: string;
title?: string | null; title: string;
from?: string | null; from: string;
to?: string | null; to?: string | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@ -366,8 +394,8 @@ export interface Menu {
*/ */
export interface Setting { export interface Setting {
id: string; id: string;
adminLanguage?: ('de' | 'fr' | 'it' | 'en') | null; adminLanguage: 'de' | 'fr' | 'it' | 'en';
contactEmailsTo?: string | null; contactEmailsTo: string;
updatedAt?: string | null; updatedAt?: string | null;
createdAt?: string | null; createdAt?: string | null;
} }

@ -3,6 +3,7 @@ import { Locale, buildConfig } from "payload";
import { defaultLocale, locales } from "@/i18n/settings"; import { defaultLocale, locales } from "@/i18n/settings";
import { About } from "@/globals/About"; import { About } from "@/globals/About";
import { Announcement } from "@/collections/Announcement";
import { Contact } from "@/globals/Contact"; import { Contact } from "@/globals/Contact";
import { FoodDeclaration } from "@/collections/FoodDeclaration"; import { FoodDeclaration } from "@/collections/FoodDeclaration";
import { Gallery } from "@/globals/Gallery"; import { Gallery } from "@/globals/Gallery";
@ -46,6 +47,7 @@ export default buildConfig({
MenuCategory, MenuCategory,
MenuItemTag, MenuItemTag,
FoodDeclaration, FoodDeclaration,
Announcement,
Vacation, Vacation,
Holiday, Holiday,
], ],

@ -0,0 +1,8 @@
import { z } from "zod";
export const contactSchema = z.object({
name: z.string().min(4),
email: z.string().email(),
subject: z.string().min(4),
message: z.string().min(10),
});

@ -0,0 +1,36 @@
import { I18nProviderClient, useI18n } from "@/i18n/client";
import { Options, render } from "@react-email/render";
import React from "react";
import { defaultLocale } from "@/i18n/settings";
import { getI18n } from "@/i18n/server";
type T = Awaited<ReturnType<typeof getI18n>>;
function I18nWrapper<P extends { t: T }>({
component: Component,
props,
}: {
component: React.ComponentType<P>;
props: P;
}) {
const t = useI18n();
return <Component {...props} t={t} />;
}
export function i18nProviderWrapper<P extends { t: T }>(Component: React.ComponentType<P>) {
return function I18nProviderWrapper(props: P & { locale?: string }) {
return (
<I18nProviderClient locale={props.locale ?? defaultLocale}>
<I18nWrapper component={Component} props={props} />
</I18nProviderClient>
);
};
}
export function renderEmail<P extends { t: T }>(Component: React.ComponentType<P>) {
return async function (props: P, opts?: Options) {
return await render(<Component {...props} />, opts);
};
}
Loading…
Cancel
Save