feat: implement menu and contact pages with opening times, support for iframe embed and menu item images, variants and descriptions

main
RaviAnand Mohabir 3 months ago
parent d436902d25
commit ea6a15e99a

@ -2,3 +2,5 @@ export * from "./about";
export * from "./contact";
export * from "./gallery";
export * from "./home";
export * from "./menu";

@ -0,0 +1,12 @@
import { Options } from "node_modules/payload/dist/collections/operations/local/find";
import { getPayload } from "@/utils/payload";
export const getMenuCategories = async () => {
const payload = await getPayload();
return payload.find({ collection: "menu-category", pagination: false });
};
export const getMenuItems = async (opts: Omit<Options<"menu-item">, "collection">) => {
const payload = await getPayload();
return payload.find({ collection: "menu-item", ...opts });
};

@ -0,0 +1,6 @@
import { getPayload } from "@/utils/payload";
export const getOpeningTimes = async () => {
const payload = await getPayload();
return await payload.find({ collection: "opening-time", sort: "from", pagination: false });
};

@ -1 +1,78 @@
export default async function Contact() {}
import { Box, HStack, Stack } from "@styled-system/jsx";
import { getAbout, getContact } from "@/api";
import { Heading } from "@/components/ui/heading";
import { Text } from "@/components/ui/text";
import { getI18n } from "@/i18n/server";
import { getOpeningTimes } from "@/api/openingTimes";
import { styled } from "@styled-system/jsx";
export default async function Contact() {
const t = await getI18n();
const about = await getAbout();
const contact = await getContact();
const openingTimes = await getOpeningTimes();
return (
<Stack p={6} gap={10}>
<Heading as="h1" size="3xl">
{t("general.contact")}
</Heading>
<Stack>
<Heading as="h3" size="xl">
{t("general.openingTimes")}
</Heading>
{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>
))}
</Stack>
<Stack>
<Heading as="h3" size="xl">
{t("general.address")}
</Heading>
<HStack gap={20}>
{contact.address.embeddedMaps && (
<styled.div
dangerouslySetInnerHTML={{ __html: contact.address.embeddedMaps }}
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>
<Text>{contact.phone}</Text>
</HStack>
)}
{contact.email && (
<HStack>
<Text fontWeight="bold">{t("general.email")}</Text>
<Text>{contact.email}</Text>
</HStack>
)}
</Stack>
</HStack>
</Stack>
</Stack>
);
}

@ -23,13 +23,15 @@ export default async function RootLayout({
}) {
return (
<html lang={locale}>
<styled.body className={moderustic.className}>
<Navbar />
<styled.main mt={20} pb={20}>
<I18nProviderClient locale={locale}>{children}</I18nProviderClient>{" "}
</styled.main>
<Footer />
</styled.body>
<I18nProviderClient locale={locale}>
<styled.body className={moderustic.className}>
<Navbar />
<styled.main mt={20} pb={20}>
{children}
</styled.main>
<Footer />
</styled.body>
</I18nProviderClient>
</html>
);
}

@ -0,0 +1,77 @@
import { Box, HStack, Stack } from "@styled-system/jsx";
import { HoverCard } from "@/components/ui/hover-card";
import { IconButton } from "@/components/ui/icon-button";
import Image from "next/image";
import { Image as ImageIcon } from "lucide-react";
import { Media } from "@/payload-types";
import RichText from "@/components/rich-text";
import { TabContentBaseProps } from "@ark-ui/react";
import { Tabs } from "@/components/ui/tabs";
import { Text } from "@/components/ui/text";
import { css } from "@styled-system/css";
import { formatToCHF } from "@/utils/formatters";
import { getMenuItems } from "@/api";
import { locales } from "@/i18n/settings";
export default async function CategoryTabContent({
locale,
...props
}: { locale: (typeof locales)[number] } & TabContentBaseProps) {
const menuItems = await getMenuItems({
locale,
where: { category: { equals: props.value } },
pagination: false,
});
return (
<Tabs.Content {...props}>
<Stack>
{menuItems.docs.map((mi) => (
<HStack key={mi.id} alignItems="start">
<Stack marginRight="auto">
<HStack>
<Text>{mi.name}</Text>
{mi.image && (
<HoverCard.Root>
<HoverCard.Trigger asChild>
<IconButton variant="ghost">
<ImageIcon />
</IconButton>
</HoverCard.Trigger>
<HoverCard.Positioner>
<HoverCard.Content>
<HoverCard.Arrow>
<HoverCard.ArrowTip />
</HoverCard.Arrow>
<Box w={250} h={150} position="relative">
<Image
src={(mi.image as Media).url!}
alt={(mi.image as Media).alt ?? ""}
className={css({ objectFit: "cover" })}
fill
/>
</Box>
</HoverCard.Content>
</HoverCard.Positioner>
</HoverCard.Root>
)}
</HStack>
{mi.description && (
<RichText content={mi.description} className={css({ color: "fg.muted" })} />
)}
</Stack>
<HStack justify="end">
{mi.variants.map((v) => (
<Stack key={v.id} align="end">
<Text>{formatToCHF(v.price!)}</Text>
<Text color="fg.muted">{v.title}</Text>
</Stack>
))}
</HStack>
</HStack>
))}
</Stack>
</Tabs.Content>
);
}

@ -1 +1,36 @@
export default async function Menu() {}
import { Box, Stack } from "@styled-system/jsx";
import CategoryTabContent from "./category-tab-content";
import { Heading } from "@/components/ui/heading";
import { Params } from "../shared";
import { Tabs } from "@/components/ui/tabs";
import { getI18n } from "@/i18n/server";
import { getMenuCategories } from "@/api";
export default async function Menu({ params: { locale } }: { params: Params }) {
const t = await getI18n();
const menuCategories = await getMenuCategories();
return (
<Stack p={6} align="center">
<Heading as="h1" size="3xl">
{t("general.menu")}
</Heading>
<Box maxW="3xl" w="100%">
<Tabs.Root defaultValue={menuCategories.docs[0].id} orientation="horizontal" w="100%">
<Tabs.List>
{menuCategories.docs.map((mc) => (
<Tabs.Trigger key={mc.id} value={mc.id}>
{mc.name}
</Tabs.Trigger>
))}
<Tabs.Indicator />
</Tabs.List>
{menuCategories.docs.map((mc) => (
<CategoryTabContent key={mc.id} value={mc.id} locale={locale}></CategoryTabContent>
))}
</Tabs.Root>
</Box>
</Stack>
);
}

@ -0,0 +1,3 @@
import { locales } from "@/i18n/settings";
export type Params = { locale: (typeof locales)[number] };

@ -51,6 +51,7 @@ export const MenuItem: CollectionConfig = {
{
name: "price",
type: "number",
required: true,
},
],
},

@ -3,6 +3,7 @@ import { getAbout, getContact } from "@/api";
import { IconButton } from "@/components/ui/icon-button";
import { SiFacebook } from "@icons-pack/react-simple-icons";
import { Text } from "@/components/ui/text";
import { stack } from "@styled-system/patterns";
export default async function Footer() {
@ -13,13 +14,13 @@ export default async function Footer() {
<styled.footer h={60} p={8} className={stack({ gap: 4, justify: "end" })}>
<HStack justify="space-between" alignItems="start">
<Box>
<styled.p fontWeight="bold">{about.name}</styled.p>
<styled.p>
<Text fontWeight="bold">{about.name}</Text>
<Text>
{contact.address.street} {contact.address.number}
</styled.p>
<styled.p>
</Text>
<Text>
{contact.address.zip} {contact.address.area}
</styled.p>
</Text>
</Box>
<HStack>
{(contact.socialLinks ?? []).map((sl) => (
@ -35,7 +36,7 @@ export default async function Footer() {
{contact.email && <styled.a href={`mailto:${contact.email}`}>{contact.email}</styled.a>}
</Stack>
</HStack>
<styled.p textAlign="center">Powered by Jenyus</styled.p>
<Text textAlign="center">Powered by Jenyus</Text>
</styled.footer>
);
}

@ -0,0 +1,34 @@
"use client";
import { useChangeLocale, useCurrentLocale, useI18n } from "@/i18n/client";
import { Button } from "@/components/ui/button";
import { Menu } from "@/components/ui/menu";
import { locales } from "@/i18n/settings";
export default function LanguagePicker(props: Menu.RootProps) {
const changeLocale = useChangeLocale();
const locale = useCurrentLocale();
const t = useI18n();
return (
<Menu.Root {...props} positioning={{ offset: { crossAxis: -35 } }}>
<Menu.Trigger asChild>
<Button variant="outline" size={props.size}>
{t(`general.${locale}`)}
</Button>
</Menu.Trigger>
<Menu.Positioner>
<Menu.Content right={0}>
<Menu.ItemGroup>
{locales.map((locale) => (
<Menu.Item asChild key={locale} value={locale} onClick={() => changeLocale(locale)}>
<Button>{t(`general.${locale}`)}</Button>
</Menu.Item>
))}
</Menu.ItemGroup>
</Menu.Content>
</Menu.Positioner>
</Menu.Root>
);
}

@ -1,15 +1,20 @@
"use client";
import { About, Media } from "@/payload-types";
import { Menu, X } from "lucide-react";
import { IconButton } from "@/components/ui/icon-button";
import Image from "next/image";
import Link from "next/link";
import NavLink from "@/components/layout/nav-link";
import { css } from "@styled-system/css";
import { styled } from "@styled-system/jsx";
import { useI18n } from "@/i18n/client";
import { useState } from "react";
export default function MobileNav() {
export default function MobileNav({ about }: { about: About }) {
const [show, setShow] = useState(false);
const t = useI18n();
return (
<styled.div className={css({ display: "block", sm: { display: "none" } })}>
@ -39,15 +44,31 @@ export default function MobileNav() {
</IconButton>
</styled.div>
<NavLink href="/about">Über uns</NavLink>
<NavLink href="/menu">Menü</NavLink>
{about.logo ? (
<Link href="/" className={css({ h: 16, w: 200, position: "relative" })}>
<Image
src={(about.logo as Media).url!}
alt={(about.logo as Media).alt ?? about.name}
className={css({ objectFit: "contain" })}
fill
/>
</Link>
) : (
<Link href="/" className={css({ fontSize: 24 })}>
{about.name}
</Link>
)}
<NavLink href="/">{t("general.home")}</NavLink>
<NavLink href="/about">{t("general.about")}</NavLink>
<NavLink href="/menu">{t("general.menu")}</NavLink>
<NavLink
href="/contact"
type="button"
className={css({ alignSelf: "stretch", textAlign: "center", marginTop: "auto" })}
>
Kontakt
{t("general.contact")}
</NavLink>
</styled.div>
</styled.div>

@ -1,4 +1,5 @@
import Image from "next/image";
import LanguagePicker from "./language-picker";
import Link from "next/link";
import { Media } from "@/payload-types";
import MobileNav from "./mobile-nav";
@ -59,7 +60,9 @@ export default async function Navbar() {
{t("general.contact")}
</NavLink>
<MobileNav />
<LanguagePicker />
<MobileNav about={about} />
</styled.nav>
);
}

@ -11,7 +11,7 @@ type Props = {
enableProse?: boolean;
};
const RichText: React.FC<Props> = ({ className, content, enableGutter = true }) => {
const RichText: React.FC<Props> = ({ className, content, enableGutter = false }) => {
if (!content) {
return null;
}

@ -0,0 +1 @@
export { Heading, type HeadingProps } from './styled/heading'

@ -0,0 +1 @@
export * as HoverCard from './styled/hover-card'

@ -0,0 +1 @@
export * as Menu from './styled/menu'

@ -0,0 +1,10 @@
import { styled } from 'styled-system/jsx'
import { type TextVariantProps, text } from 'styled-system/recipes'
import type { ComponentProps, StyledComponent } from 'styled-system/types'
type TextProps = TextVariantProps & { as?: React.ElementType }
export type HeadingProps = ComponentProps<typeof Heading>
export const Heading = styled('h2', text, {
defaultProps: { variant: 'heading' },
}) as StyledComponent<'h2', TextProps>

@ -0,0 +1,45 @@
'use client'
import type { Assign } from '@ark-ui/react'
import { HoverCard } from '@ark-ui/react/hover-card'
import { type HoverCardVariantProps, hoverCard } from 'styled-system/recipes'
import type { ComponentProps, HTMLStyledProps } from 'styled-system/types'
import { createStyleContext } from './utils/create-style-context'
const { withRootProvider, withContext } = createStyleContext(hoverCard)
export type RootProviderProps = ComponentProps<typeof RootProvider>
export const RootProvider = withRootProvider<
Assign<HoverCard.RootProviderProps, HoverCardVariantProps>
>(HoverCard.RootProvider)
export type RootProps = ComponentProps<typeof Root>
export const Root = withRootProvider<Assign<HoverCard.RootProps, HoverCardVariantProps>>(
HoverCard.Root,
)
export const Arrow = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, HoverCard.ArrowBaseProps>
>(HoverCard.Arrow, 'arrow')
export const ArrowTip = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, HoverCard.ArrowTipBaseProps>
>(HoverCard.ArrowTip, 'arrowTip')
export const Content = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, HoverCard.ContentBaseProps>
>(HoverCard.Content, 'content')
export const Positioner = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, HoverCard.PositionerBaseProps>
>(HoverCard.Positioner, 'positioner')
export const Trigger = withContext<
HTMLButtonElement,
Assign<HTMLStyledProps<'button'>, HoverCard.TriggerBaseProps>
>(HoverCard.Trigger, 'trigger')
export { HoverCardContext as Context } from '@ark-ui/react/hover-card'

@ -0,0 +1,103 @@
'use client'
import type { Assign } from '@ark-ui/react'
import { Menu } from '@ark-ui/react/menu'
import { type MenuVariantProps, menu } from 'styled-system/recipes'
import type { ComponentProps, HTMLStyledProps } from 'styled-system/types'
import { createStyleContext } from './utils/create-style-context'
const { withRootProvider, withContext } = createStyleContext(menu)
export type RootProviderProps = ComponentProps<typeof RootProvider>
export const RootProvider = withRootProvider<Assign<Menu.RootProviderProps, MenuVariantProps>>(
Menu.RootProvider,
)
export type RootProps = ComponentProps<typeof Root>
export const Root = withRootProvider<Assign<Menu.RootProps, MenuVariantProps>>(Menu.Root)
export const Arrow = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, Menu.ArrowBaseProps>
>(Menu.Arrow, 'arrow')
export const ArrowTip = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, Menu.ArrowTipBaseProps>
>(Menu.ArrowTip, 'arrowTip')
export const CheckboxItem = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, Menu.CheckboxItemBaseProps>
>(Menu.CheckboxItem, 'item')
export const Content = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, Menu.ContentBaseProps>
>(Menu.Content, 'content')
export const ContextTrigger = withContext<
HTMLButtonElement,
Assign<HTMLStyledProps<'button'>, Menu.ContextTriggerBaseProps>
>(Menu.ContextTrigger, 'contextTrigger')
export const Indicator = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, Menu.IndicatorBaseProps>
>(Menu.Indicator, 'indicator')
export const ItemGroupLabel = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, Menu.ItemGroupLabelBaseProps>
>(Menu.ItemGroupLabel, 'itemGroupLabel')
export const ItemGroup = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, Menu.ItemGroupBaseProps>
>(Menu.ItemGroup, 'itemGroup')
export const ItemIndicator = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, Menu.ItemIndicatorBaseProps>
>(Menu.ItemIndicator, 'itemIndicator')
export const Item = withContext<HTMLDivElement, Assign<HTMLStyledProps<'div'>, Menu.ItemBaseProps>>(
Menu.Item,
'item',
)
export const ItemText = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, Menu.ItemTextBaseProps>
>(Menu.ItemText, 'itemText')
export const Positioner = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, Menu.PositionerBaseProps>
>(Menu.Positioner, 'positioner')
export const RadioItemGroup = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, Menu.RadioItemGroupBaseProps>
>(Menu.RadioItemGroup, 'itemGroup')
export const RadioItem = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, Menu.RadioItemBaseProps>
>(Menu.RadioItem, 'item')
export const Separator = withContext<
HTMLHRElement,
Assign<HTMLStyledProps<'hr'>, Menu.SeparatorBaseProps>
>(Menu.Separator, 'separator')
export const TriggerItem = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, Menu.TriggerItemBaseProps>
>(Menu.TriggerItem, 'triggerItem')
export const Trigger = withContext<
HTMLButtonElement,
Assign<HTMLStyledProps<'button'>, Menu.TriggerBaseProps>
>(Menu.Trigger, 'trigger')
export { MenuContext as Context } from '@ark-ui/react/menu'

@ -0,0 +1,42 @@
'use client'
import type { Assign } from '@ark-ui/react'
import { Tabs } from '@ark-ui/react/tabs'
import { type TabsVariantProps, tabs } from 'styled-system/recipes'
import type { ComponentProps, HTMLStyledProps } from 'styled-system/types'
import { createStyleContext } from './utils/create-style-context'
const { withProvider, withContext } = createStyleContext(tabs)
export type RootProviderProps = ComponentProps<typeof RootProvider>
export const RootProvider = withProvider<
HTMLDivElement,
Assign<Assign<HTMLStyledProps<'div'>, Tabs.RootProviderBaseProps>, TabsVariantProps>
>(Tabs.RootProvider, 'root')
export type RootProps = ComponentProps<typeof Root>
export const Root = withProvider<
HTMLDivElement,
Assign<Assign<HTMLStyledProps<'div'>, Tabs.RootBaseProps>, TabsVariantProps>
>(Tabs.Root, 'root')
export const Content = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, Tabs.ContentBaseProps>
>(Tabs.Content, 'content')
export const Indicator = withContext<
HTMLDivElement,
Assign<HTMLStyledProps<'div'>, Tabs.IndicatorBaseProps>
>(Tabs.Indicator, 'indicator')
export const List = withContext<HTMLDivElement, Assign<HTMLStyledProps<'div'>, Tabs.ListBaseProps>>(
Tabs.List,
'list',
)
export const Trigger = withContext<
HTMLButtonElement,
Assign<HTMLStyledProps<'button'>, Tabs.TriggerBaseProps>
>(Tabs.Trigger, 'trigger')
export { TabsContext as Context } from '@ark-ui/react/tabs'

@ -0,0 +1,8 @@
import { styled } from 'styled-system/jsx'
import { type TextVariantProps, text } from 'styled-system/recipes'
import type { ComponentProps, StyledComponent } from 'styled-system/types'
type ParagraphProps = TextVariantProps & { as?: React.ElementType }
export type TextProps = ComponentProps<typeof Text>
export const Text = styled('p', text) as StyledComponent<'p', ParagraphProps>

@ -0,0 +1 @@
export * as Tabs from './styled/tabs'

@ -0,0 +1 @@
export { Text, type TextProps } from './styled/text'

@ -48,6 +48,24 @@ export const Contact: GlobalConfig = {
},
],
},
{
name: "embeddedMaps",
type: "code",
},
{
name: "embeddedMapsNotice",
type: "ui",
admin: {
components: {
Field: () => (
<p>
Use a generator like <a href="https://www.maps.ie/create-google-map/">maps.ie</a>{" "}
to create a Google Maps embed.
</p>
),
},
},
},
],
},
{

@ -2,7 +2,8 @@
import { createI18nClient } from "next-international/client";
export const { useI18n, useScopedI18n, I18nProviderClient } = createI18nClient({
de: () => import("./de"),
en: () => import("./en"),
});
export const { useI18n, useScopedI18n, I18nProviderClient, useChangeLocale, useCurrentLocale } =
createI18nClient({
de: () => import("./de"),
en: () => import("./en"),
});

@ -1,7 +1,25 @@
export default {
general: {
home: "Startseite",
about: "Über uns",
menu: "Menü",
menu: "Speisekarte",
contact: "Kontakt",
address: "Addresse",
phoneNumber: "Telefonnummer",
email: "E-Mail",
openingTimes: "Öffnungszeiten",
en: "Englisch",
de: "Deutsch",
fr: "Französisch",
it: "Italienisch",
},
days: {
"0": "Montag",
"1": "Dienstag",
"2": "Mittwoch",
"3": "Donnerstag",
"4": "Freitag",
"5": "Samstag",
"6": "Sonntag",
},
} as const;

@ -3,5 +3,10 @@ export default {
about: "About",
menu: "Menu",
contact: "Contact",
home: "Home",
en: "English",
de: "German",
fr: "French",
it: "Italian",
},
} as const;

@ -132,7 +132,7 @@ export interface MenuItem {
tags?: (string | MenuItemTag)[] | null;
variants: {
title?: string | null;
price?: number | null;
price: number;
id?: string | null;
}[];
updatedAt: string;
@ -334,6 +334,7 @@ export interface Contact {
number: number;
zip: number;
area: string;
embeddedMaps?: string | null;
};
socialLinks?:
| {

@ -0,0 +1,2 @@
export const formatToCHF = (val: number) =>
Intl.NumberFormat("de-CH", { style: "currency", currency: "CHF" }).format(val);
Loading…
Cancel
Save