diff --git a/.env.example b/.env.example index 67a64b3..d0b5591 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,7 @@ # General NODE_ENV="development" +NODE_OPTIONS="--max_old_space_size=16384" NEXT_TELEMETRY_DISABLED="true" NEXT_PUBLIC_SITE_URL="http://localhost:3000" diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 841aaa2..344776d 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -21,6 +21,7 @@ jobs: name: Performance Audit runs-on: ubuntu-latest env: + CI: true NEXT_TELEMETRY_DISABLED: true NODE_ENV: "production" DATABASE_HOST: "localhost" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 655cd18..dd0814b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,9 +79,10 @@ Here is a list of documentations that will help you contribute to the project: - [Next.js](https://nextjs.org/docs) - Framework for routing and server-side rendering - [Next-intl](https://next-intl-docs.vercel.app/) - Internationalization library - [nuqs](https://nuqs.47ng.com/docs/installation) - Easy to use query params -- [BlockNote](https://www.blocknotejs.org/docs) - Tool for markdown textboxes -- [React Hook Form](https://react-hook-form.com/get-started) - When we need to handle form validation +- [Plate](https://platejs.org) - Tool for rich text editing - [Tanstack Query](https://tanstack.com/query/latest/docs/framework/react/overview) - TRPC wraps Tanstack Query which is how we fetch data from the backend +- [Tanstack Table](https://tanstack.com/table/latest/docs/introduction) - For dynamic tables with filtering, sorting, pagination etc +- [Tanstack Form](https://tanstack.com/form/latest/docs/overview) - When we need to handle form validation #### Styling diff --git a/Dockerfile b/Dockerfile index b16432f..c47daf3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,7 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . +ENV CI=true ENV NODE_ENV=production ENV SKIP_ENV_VALIDATION=true @@ -27,6 +28,7 @@ RUN bun run build FROM base AS runner WORKDIR /app +ENV CI=true ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=true ENV SKIP_ENV_VALIDATION=true diff --git a/README.md b/README.md index 656dfe1..1d687cd 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The overhauled website for the [Hackerspace NTNU](https://www.hackerspace-ntnu.no/) student organization. -## Did you encouter an issue with the website? +## Did you encounter an issue with the website? Please report it as an [issue](https://github.com/hackerspace-ntnu/website-next/issues)! diff --git a/bun.lockb b/bun.lockb index 68cb166..27c81c2 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/drizzle.config.ts b/drizzle.config.ts index c39240d..115647c 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,10 +1,12 @@ import { env } from '@/env'; import { defineConfig } from 'drizzle-kit'; -export default defineConfig({ +const config = defineConfig({ schema: './src/server/db/schema/*.ts', dialect: 'postgresql', dbCredentials: { url: `postgresql://${env.DATABASE_USER}:${env.DATABASE_PASSWORD}@${env.DATABASE_HOST}:${env.DATABASE_PORT}/${env.DATABASE_NAME}`, }, }); + +export default config; diff --git a/lighthouserc.cjs b/lighthouserc.cjs index 210334c..6abc5ee 100644 --- a/lighthouserc.cjs +++ b/lighthouserc.cjs @@ -2,7 +2,7 @@ const PAGES_EXCLUDED = ['news', 'storage']; // Do not convert into an ES6 export. // lighthouse-ci (as of 0.14.0) uses require() to import, and this is not supported with ES6 modules. -module.exports = { +const config = { ci: { collect: { url: [ @@ -31,6 +31,7 @@ module.exports = { 'heading-order': 'off', 'largest-contentful-paint': 'off', 'render-blocking-resources': 'off', + 'target-size': 'off', }, }, { @@ -42,8 +43,12 @@ module.exports = { 'heading-order': 'off', 'largest-contentful-paint': 'off', 'render-blocking-resources': 'off', + 'target-size': 'off', interactive: 'off', 'uses-responsive-images': 'off', // Should be removed when we obtain images from backend + 'image-aspect-ratio': 'off', // Should be removed when we obtain images from backend + 'image-size-responsive': 'off', // Should be removed when we obtain images from backend + 'max-potential-fid': 'off', }, }, { @@ -55,12 +60,16 @@ module.exports = { 'heading-order': 'off', 'largest-contentful-paint': 'off', 'render-blocking-resources': 'off', + 'target-size': 'off', 'unused-javascript': 'off', 'cumulative-layout-shift': 'off', // We don't always know how many items are in the cart, which can lead to layout shifts when loading completes 'max-potential-fid': 'off', + 'image-aspect-ratio': 'off', // Should be removed when we obtain images from backend }, }, ], }, }, }; + +module.exports = config; diff --git a/messages/en.json b/messages/en.json index 5c0c922..62e259c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12,7 +12,16 @@ "page": "page", "category": "category", "sort": "sort", - "photoOf": "Photo of {name}" + "photoOf": "Photo of {name}", + "today": "Today", + "selected": "Selected", + "week": "Week", + "nextMonth": "Next month", + "previousMonth": "Previous month", + "selectMonth": "Select month", + "selectYear": "Select year", + "pickDate": "Pick a date", + "dateFormat": "dd/MM/yyyy" }, "error": { "notFound": "404 - Page not found", diff --git a/messages/no.json b/messages/no.json index 62a6fd0..0c5810a 100644 --- a/messages/no.json +++ b/messages/no.json @@ -12,7 +12,16 @@ "page": "side", "category": "kategori", "sort": "sortering", - "photoOf": "Bilde av {name}" + "photoOf": "Bilde av {name}", + "today": "I dag", + "selected": "Valgt", + "week": "Uke", + "nextMonth": "Neste måned", + "previousMonth": "Forrige måned", + "selectMonth": "Velg måned", + "selectYear": "Velg år", + "pickDate": "Velg en dato", + "dateFormat": "dd.MM.yyyy" }, "error": { "notFound": "404 - Siden ble ikke funnet", diff --git a/next-sitemap.config.js b/next-sitemap.config.js deleted file mode 100644 index 218af13..0000000 --- a/next-sitemap.config.js +++ /dev/null @@ -1,8 +0,0 @@ -/** @type {import('next-sitemap').IConfig} */ -const config = { - siteUrl: process.env.NEXT_PUBLIC_SITE_URL ?? '', - generateRobotsTxt: true, - generateIndexSitemap: false, -}; - -export default config; diff --git a/next.config.js b/next.config.ts similarity index 68% rename from next.config.js rename to next.config.ts index 819aad4..0c57969 100644 --- a/next.config.js +++ b/next.config.ts @@ -1,10 +1,9 @@ +import type { NextConfig } from 'next'; import nextIntl from 'next-intl/plugin'; -await import('./src/env.js'); const withNextIntl = nextIntl('./src/lib/locale/request.ts'); -/** @type {import("next").NextConfig} */ -const config = { +const config: NextConfig = { reactStrictMode: true, output: 'standalone', }; diff --git a/package.json b/package.json index 43e37e9..dc172f9 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,12 @@ "private": true, "type": "module", "scripts": { - "prepare": "if [ \"$NODE_ENV\" != \"production\" ]; then lefthook install; fi", - "dev": "next dev", + "prepare": "lefthook install", + "dev": "next dev --turbopack", "lint": "biome check --write", "prebuild": "next telemetry disable", "build": "next build", - "postbuild": "next-sitemap && mkdir -p .next/standalone/public .next/standalone/.next/static && cp -r public/* .next/standalone/public && cp -r .next/static/* .next/standalone/.next/static", + "postbuild": "mkdir -p .next/standalone/public .next/standalone/.next/static && cp -r public/* .next/standalone/public && cp -r .next/static/* .next/standalone/.next/static", "start": "bun run .next/standalone/server.js", "db:start": "docker-compose up db", "db:generate": "drizzle-kit generate", @@ -20,19 +20,21 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.637.0", - "@hookform/resolvers": "^3.9.0", "@lucia-auth/adapter-drizzle": "^1.1.0", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.1", "@t3-oss/env-nextjs": "^0.10.1", + "@tanstack/react-form": "^0.34.1", "@tanstack/react-query": "^5.53.1", + "@tanstack/zod-form-adapter": "^0.34.1", "@trpc/client": "^11.0.0-rc.490", "@trpc/react-query": "^11.0.0-rc.490", "@trpc/server": "^11.0.0-rc.490", @@ -43,33 +45,31 @@ "drizzle-orm": "^0.33.0", "lucia": "^3.2.0", "lucide-react": "^0.396.0", - "next": "^14.2.10", - "next-intl": "^3.18.1", - "next-themes": "^0.3.0", - "nuqs": "^1.17.4", + "next": "15.0.1", + "next-intl": "^3.23.5", + "next-themes": "1.0.0-beta.0", + "nuqs": "^2.0.4", "postgres": "^3.4.4", - "react": "^18.3.1", - "react-day-picker": "8.10.1", - "react-dom": "^18.3.1", - "react-hook-form": "^7.53.0", + "react": "19.0.0-rc-69d4b800-20241021", + "react-day-picker": "^9.1.4", + "react-dom": "19.0.0-rc-69d4b800-20241021", "reading-time": "^1.5.0", - "sharp": "^0.33.4", "superjson": "^2.2.1", "tailwind-merge": "^2.5.2", + "vaul": "^1.1.0", "zod": "^3.23.8" }, "devDependencies": { "@biomejs/biome": "^1.9.1", "@fluid-tailwind/tailwind-merge": "^0.0.2", "@types/node": "^20.14.8", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", + "@types/react": "npm:types-react@19.0.0-rc.1", + "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1", "autoprefixer": "^10.4.20", "client-only": "^0.0.1", "drizzle-kit": "^0.24.1", "fluid-tailwind": "^1.0.3", "lefthook": "^1.7.14", - "next-sitemap": "^4.2.3", "postcss": "^8.4.38", "server-only": "^0.0.1", "tailwind-scrollbar": "^3.1.0", @@ -78,5 +78,9 @@ "tailwindcss-radix": "^3.0.5", "typescript": "^5.5.0" }, + "overrides": { + "@types/react": "npm:types-react@19.0.0-rc.1", + "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1" + }, "packageManager": "bun@1.1.12" } diff --git a/src/app/[locale]/(default)/about/page.tsx b/src/app/[locale]/(default)/about/page.tsx index a16200f..f21eabd 100644 --- a/src/app/[locale]/(default)/about/page.tsx +++ b/src/app/[locale]/(default)/about/page.tsx @@ -1,10 +1,12 @@ -import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; export async function generateMetadata({ - params: { locale }, + params, }: { - params: { locale: string }; + params: Promise<{ locale: string }>; }) { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: 'layout' }); return { @@ -12,11 +14,12 @@ export async function generateMetadata({ }; } -export default function AboutPage({ - params: { locale }, +export default async function AboutPage({ + params, }: { - params: { locale: string }; + params: Promise<{ locale: string }>; }) { - unstable_setRequestLocale(locale); + const { locale } = await params; + setRequestLocale(locale); return
this should be about page
; } diff --git a/src/app/[locale]/(default)/events/page.tsx b/src/app/[locale]/(default)/events/page.tsx index 1477014..583d9d6 100644 --- a/src/app/[locale]/(default)/events/page.tsx +++ b/src/app/[locale]/(default)/events/page.tsx @@ -1,10 +1,12 @@ -import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; export async function generateMetadata({ - params: { locale }, + params, }: { - params: { locale: string }; + params: Promise<{ locale: string }>; }) { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: 'layout' }); return { @@ -12,11 +14,12 @@ export async function generateMetadata({ }; } -export default function EventsPage({ - params: { locale }, +export default async function EventsPage({ + params, }: { - params: { locale: string }; + params: Promise<{ locale: string }>; }) { - unstable_setRequestLocale(locale); + const { locale } = await params; + setRequestLocale(locale); return
This should be events page
; } diff --git a/src/app/[locale]/(default)/layout.tsx b/src/app/[locale]/(default)/layout.tsx index c01ad96..b9fc1ae 100644 --- a/src/app/[locale]/(default)/layout.tsx +++ b/src/app/[locale]/(default)/layout.tsx @@ -1,18 +1,21 @@ import { Footer } from '@/components/layout/Footer'; import { Header } from '@/components/layout/Header'; import { Main } from '@/components/layout/Main'; -import { unstable_setRequestLocale } from 'next-intl/server'; +import { setRequestLocale } from 'next-intl/server'; type DefaultLayoutProps = { children: React.ReactNode; - params: { locale: string }; + params: Promise<{ locale: string }>; }; -export default function DefaultLayout({ - children, - params: { locale }, -}: DefaultLayoutProps) { - unstable_setRequestLocale(locale); +export default async function DefaultLayout(props: DefaultLayoutProps) { + const params = await props.params; + + const { locale } = params; + + const { children } = props; + + setRequestLocale(locale); return ( <>
diff --git a/src/app/[locale]/(default)/news/(main)/layout.tsx b/src/app/[locale]/(default)/news/(main)/layout.tsx index feebab9..4664b3c 100644 --- a/src/app/[locale]/(default)/news/(main)/layout.tsx +++ b/src/app/[locale]/(default)/news/(main)/layout.tsx @@ -1,6 +1,5 @@ import { SquarePenIcon } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { unstable_setRequestLocale } from 'next-intl/server'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Link } from '@/lib/locale/navigation'; @@ -8,15 +7,17 @@ import { Button } from '@/components/ui/Button'; type NewsHeaderLayoutProps = { children: React.ReactNode; - params: { locale: string }; + params: Promise<{ locale: string }>; }; -export default function NewsHeaderLayout({ +export default async function NewsHeaderLayout({ + params, children, - params: { locale }, }: NewsHeaderLayoutProps) { - unstable_setRequestLocale(locale); - const t = useTranslations('news'); + const { locale } = await params; + + setRequestLocale(locale); + const t = await getTranslations('news'); return ( <>
diff --git a/src/app/[locale]/(default)/news/(main)/loading.tsx b/src/app/[locale]/(default)/news/(main)/loading.tsx index 8d26a19..7507dbd 100644 --- a/src/app/[locale]/(default)/news/(main)/loading.tsx +++ b/src/app/[locale]/(default)/news/(main)/loading.tsx @@ -3,7 +3,7 @@ import { CardGridSkeleton } from '@/components/news/CardGridSkeleton'; import { ItemGridSkeleton } from '@/components/news/ItemGridSkeleton'; import { Separator } from '@/components/ui/Separator'; -export default function NewsSkeleton() { +export default function NewsLoading() { return ( <> diff --git a/src/app/[locale]/(default)/news/(main)/page.tsx b/src/app/[locale]/(default)/news/(main)/page.tsx index a7bfa8d..72df9ec 100644 --- a/src/app/[locale]/(default)/news/(main)/page.tsx +++ b/src/app/[locale]/(default)/news/(main)/page.tsx @@ -1,7 +1,10 @@ import { articleMockData as articleData } from '@/mock-data/article'; -import { useTranslations } from 'next-intl'; -import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; -import { createSearchParamsCache, parseAsInteger } from 'nuqs/server'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { + type SearchParams, + createSearchParamsCache, + parseAsInteger, +} from 'nuqs/server'; import { Suspense } from 'react'; import { PaginationCarousel } from '@/components/composites/PaginationCarousel'; @@ -11,10 +14,12 @@ import { ItemGridSkeleton } from '@/components/news/ItemGridSkeleton'; import { Separator } from '@/components/ui/Separator'; export async function generateMetadata({ - params: { locale }, + params, }: { - params: { locale: string }; + params: Promise<{ locale: string }>; }) { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: 'layout' }); return { @@ -22,20 +27,21 @@ export async function generateMetadata({ }; } -export default function NewsPage({ - params: { locale }, +export default async function NewsPage({ + params, searchParams, }: { - params: { locale: string }; - searchParams: Record; + params: Promise<{ locale: string }>; + searchParams: Promise; }) { - unstable_setRequestLocale(locale); - const t = useTranslations('ui'); + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations('ui'); const searchParamsCache = createSearchParamsCache({ [t('page')]: parseAsInteger.withDefault(1), }); - const { [t('page')]: page = 1 } = searchParamsCache.parse(searchParams); + const { [t('page')]: page = 1 } = searchParamsCache.parse(await searchParams); // TODO: Button to create new article should only be visible when logged in return ( <> diff --git a/src/app/[locale]/(default)/news/[article]/page.tsx b/src/app/[locale]/(default)/news/[article]/page.tsx index a9b96f3..ca4cab0 100644 --- a/src/app/[locale]/(default)/news/[article]/page.tsx +++ b/src/app/[locale]/(default)/news/[article]/page.tsx @@ -2,8 +2,7 @@ import { articleMockData as articleData, authorMockData as authorData, } from '@/mock-data/article'; -import { useTranslations } from 'next-intl'; -import { unstable_setRequestLocale } from 'next-intl/server'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; import Image from 'next/image'; import { notFound } from 'next/navigation'; import readingTime from 'reading-time'; @@ -20,33 +19,31 @@ import { Badge } from '@/components/ui/Badge'; export async function generateMetadata({ params, }: { - params: { article: string }; + params: Promise<{ article: string }>; }) { - const article = articleData.find( - (article) => article.id === Number(params.article), - ); + const { article } = await params; + const data = articleData.find((a) => a.id === Number(article)); return { - title: article?.title, + title: data?.title, }; } -export default function ArticlePage({ +export default async function ArticlePage({ params, }: { - params: { locale: string; article: string }; + params: Promise<{ locale: string; article: string }>; }) { - unstable_setRequestLocale(params.locale); - const t = useTranslations('news'); + const { locale, article } = await params; + setRequestLocale(locale); + const t = await getTranslations('news'); - const article = articleData.find( - (article) => article.id === Number(params.article), - ); - if (!article) { + const data = articleData.find((a) => a.id === Number(article)); + if (!data) { return notFound(); } - const { minutes } = readingTime(article.content as string); // assert because its a mock data file + const { minutes } = readingTime(data.content as string); // assert because its a mock data file const author = authorData[0] as { name: string; photoUrl: string; @@ -58,14 +55,14 @@ export default function ArticlePage({
{article.title}
-

{article.title}

+

{data.title}

@@ -79,13 +76,13 @@ export default function ArticlePage({ {t('readTime', { count: Math.ceil(minutes) })}   •   - {article.date} + {data.date}
- {`${article.views} ${t('views')}`} + {`${data.views} ${t('views')}`}
-
{article.content}
+
{data.content}
); } diff --git a/src/app/[locale]/(default)/page.tsx b/src/app/[locale]/(default)/page.tsx index af70a75..2485554 100644 --- a/src/app/[locale]/(default)/page.tsx +++ b/src/app/[locale]/(default)/page.tsx @@ -1,13 +1,14 @@ import { HelloWorld } from '@/components/home/HelloWorld'; import { api } from '@/lib/api/server'; -import { unstable_setRequestLocale } from 'next-intl/server'; +import { setRequestLocale } from 'next-intl/server'; export default async function HomePage({ - params: { locale }, + params, }: { - params: { locale: string }; + params: Promise<{ locale: string }>; }) { - unstable_setRequestLocale(locale); + const { locale } = await params; + setRequestLocale(locale); const hello = await api.test.helloWorld(); return (
diff --git a/src/app/[locale]/(default)/storage/(main)/layout.tsx b/src/app/[locale]/(default)/storage/(main)/layout.tsx index 4bddd7a..5afa03c 100644 --- a/src/app/[locale]/(default)/storage/(main)/layout.tsx +++ b/src/app/[locale]/(default)/storage/(main)/layout.tsx @@ -3,22 +3,23 @@ import { SearchBar } from '@/components/composites/SearchBar'; import { SortSelector } from '@/components/composites/SortSelector'; import { SelectorsSkeleton } from '@/components/storage/SelectorsSkeleton'; import { ShoppingCartLink } from '@/components/storage/ShoppingCartLink'; -import { useTranslations } from 'next-intl'; -import { unstable_setRequestLocale } from 'next-intl/server'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Suspense } from 'react'; type StorageLayoutProps = { children: React.ReactNode; - params: { locale: string }; + params: Promise<{ locale: string }>; }; -export default function StorageLayout({ +export default async function StorageLayout({ + params, children, - params: { locale }, }: StorageLayoutProps) { - unstable_setRequestLocale(locale); - const t = useTranslations('storage'); - const tUi = useTranslations('ui'); + const { locale } = await params; + + setRequestLocale(locale); + const t = await getTranslations('storage'); + const tUi = await getTranslations('ui'); // This does not make much sense with a backend, most likely the categories in the backend will have a name in both languages and an ID const categories = [ diff --git a/src/app/[locale]/(default)/storage/(main)/loading.tsx b/src/app/[locale]/(default)/storage/(main)/loading.tsx index c510d4f..60b9b5d 100644 --- a/src/app/[locale]/(default)/storage/(main)/loading.tsx +++ b/src/app/[locale]/(default)/storage/(main)/loading.tsx @@ -2,7 +2,7 @@ import { PaginationCarouselSkeleton } from '@/components/composites/PaginationCa import { ItemCardSkeleton } from '@/components/storage/ItemCardSkeleton'; import { useId } from 'react'; -export default function StorageSkeleton() { +export default function StorageLoading() { return ( <>
diff --git a/src/app/[locale]/(default)/storage/(main)/page.tsx b/src/app/[locale]/(default)/storage/(main)/page.tsx index 9bbb49a..60da548 100644 --- a/src/app/[locale]/(default)/storage/(main)/page.tsx +++ b/src/app/[locale]/(default)/storage/(main)/page.tsx @@ -1,16 +1,20 @@ import { items } from '@/mock-data/items'; -import { useTranslations } from 'next-intl'; -import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; -import { createSearchParamsCache, parseAsInteger } from 'nuqs/server'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; +import { + type SearchParams, + createSearchParamsCache, + parseAsInteger, +} from 'nuqs/server'; import { PaginationCarousel } from '@/components/composites/PaginationCarousel'; import { ItemCard } from '@/components/storage/ItemCard'; export async function generateMetadata({ - params: { locale }, + params, }: { - params: { locale: string }; + params: Promise<{ locale: string }>; }) { + const { locale } = await params; const t = await getTranslations({ locale, namespace: 'layout' }); return { @@ -18,15 +22,17 @@ export async function generateMetadata({ }; } -export default function StoragePage({ - params: { locale }, +export default async function StoragePage({ + params, searchParams, }: { - params: { locale: string }; - searchParams: Record; + params: Promise<{ locale: string }>; + searchParams: Promise; }) { - unstable_setRequestLocale(locale); - const t = useTranslations('ui'); + const { locale } = await params; + + setRequestLocale(locale); + const t = await getTranslations('ui'); const itemsPerPage = 12; @@ -34,7 +40,7 @@ export default function StoragePage({ [t('page')]: parseAsInteger.withDefault(1), }); - const { [t('page')]: page = 1 } = searchParamsCache.parse(searchParams); + const { [t('page')]: page = 1 } = searchParamsCache.parse(await searchParams); return ( <> diff --git a/src/app/[locale]/(default)/storage/shopping-cart/layout.tsx b/src/app/[locale]/(default)/storage/shopping-cart/layout.tsx index 8235cb2..ce3981d 100644 --- a/src/app/[locale]/(default)/storage/shopping-cart/layout.tsx +++ b/src/app/[locale]/(default)/storage/shopping-cart/layout.tsx @@ -1,20 +1,21 @@ import { Button } from '@/components/ui/Button'; import { Link } from '@/lib/locale/navigation'; import { ArrowLeftIcon } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { unstable_setRequestLocale } from 'next-intl/server'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; type ShoppingCartLayoutProps = { children: React.ReactNode; - params: { locale: string }; + params: Promise<{ locale: string }>; }; -export default function StorageLayout({ +export default async function StorageLayout({ + params, children, - params: { locale }, }: ShoppingCartLayoutProps) { - unstable_setRequestLocale(locale); - const t = useTranslations('storage.shoppingCart'); + const { locale } = await params; + + setRequestLocale(locale); + const t = await getTranslations('storage.shoppingCart'); return ( <>
diff --git a/src/app/[locale]/(default)/storage/shopping-cart/loading.tsx b/src/app/[locale]/(default)/storage/shopping-cart/loading.tsx index 27310cd..80f54ed 100644 --- a/src/app/[locale]/(default)/storage/shopping-cart/loading.tsx +++ b/src/app/[locale]/(default)/storage/shopping-cart/loading.tsx @@ -2,7 +2,7 @@ import { ShoppingCartTableSkeleton } from '@/components/storage/ShoppingCartTabl import { Skeleton } from '@/components/ui/Skeleton'; import { useTranslations } from 'next-intl'; -export default function ShoppingCartSkeleton() { +export default function ShoppingCartLoading() { const t = useTranslations('storage.shoppingCart'); const tableMessages = { productId: t('productId'), diff --git a/src/app/[locale]/(default)/storage/shopping-cart/page.tsx b/src/app/[locale]/(default)/storage/shopping-cart/page.tsx index f72474e..32d35a0 100644 --- a/src/app/[locale]/(default)/storage/shopping-cart/page.tsx +++ b/src/app/[locale]/(default)/storage/shopping-cart/page.tsx @@ -1,17 +1,18 @@ import { BorrowDialog } from '@/components/storage/BorrowDialog'; import { ShoppingCartClearDialog } from '@/components/storage/ShoppingCartClearDialog'; import { ShoppingCartTable } from '@/components/storage/ShoppingCartTable'; -import { useTranslations } from 'next-intl'; -import { unstable_setRequestLocale } from 'next-intl/server'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; -export default function StorageShoppingCartPage({ - params: { locale }, +export default async function StorageShoppingCartPage({ + params, }: { - params: { locale: string }; + params: Promise<{ locale: string }>; }) { - unstable_setRequestLocale(locale); - const t = useTranslations('storage.shoppingCart'); - const tLoanForm = useTranslations('storage.loanForm'); + const { locale } = await params; + + setRequestLocale(locale); + const t = await getTranslations('storage.shoppingCart'); + const tLoanForm = await getTranslations('storage.loanForm'); const tableMessages = { tableDescription: t('tableDescription'), diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 0d842cf..fc9c103 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -1,13 +1,12 @@ import { RootProviders } from '@/components/providers/RootProviders'; import { routing } from '@/lib/locale'; import { cx } from '@/lib/utils'; -import type { Viewport } from 'next'; -import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; +import { getTranslations, setRequestLocale } from 'next-intl/server'; import { Inter, Montserrat } from 'next/font/google'; type LocaleLayoutProps = { children: React.ReactNode; - params: { locale: string }; + params: Promise<{ locale: string }>; }; const inter = Inter({ @@ -26,13 +25,10 @@ export function generateStaticParams() { return routing.locales.map((locale) => ({ locale })); } -export const viewport: Viewport = { - themeColor: '#0c0a09', -}; - export async function generateMetadata({ - params: { locale }, + params, }: Omit) { + const { locale } = await params; const t = await getTranslations({ locale, namespace: 'meta' }); return { @@ -69,11 +65,14 @@ export async function generateMetadata({ }; } -export default function LocaleLayout({ - children, - params: { locale }, -}: LocaleLayoutProps) { - unstable_setRequestLocale(locale); +export default async function LocaleLayout(props: LocaleLayoutProps) { + const params = await props.params; + + const { locale } = params; + + const { children } = props; + + setRequestLocale(locale); return ( [0]['href']; + +function getEntry(href: Href, changefreq: string, priority: number) { + return { + url: getUrl(href, routing.defaultLocale), + lastModified: new Date(), + changefreq, + priority, + alternates: { + languages: Object.fromEntries( + routing.locales.map((locale) => [locale, getUrl(href, locale)]), + ), + }, + }; +} + +function getUrl(href: Href, locale: (typeof routing.locales)[number]) { + const pathname = getPathname({ locale, href }); + return `${env.NEXT_PUBLIC_SITE_URL}${pathname}`; +} + +export default function sitemap(): MetadataRoute.Sitemap { + return [ + getEntry('/', 'yearly', 1.0), + getEntry('/about', 'monthly', 0.8), + getEntry('/news', 'weekly', 0.7), + getEntry( + { + pathname: '/news/[article]', + params: { article: '1' }, + }, + 'daily', + 0.4, + ), + getEntry('/events', 'weekly', 0.7), + getEntry('/storage', 'daily', 0.4), + ]; +} diff --git a/src/components/composites/ConfirmDialog.tsx b/src/components/composites/ConfirmDialog.tsx index 4a2c1b3..7f0d41b 100644 --- a/src/components/composites/ConfirmDialog.tsx +++ b/src/components/composites/ConfirmDialog.tsx @@ -1,16 +1,16 @@ 'use client'; -import { Button, type buttonVariants } from '@/components/ui/Button'; import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/Dialog'; + ResponsiveDialog, + ResponsiveDialogClose, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, + ResponsiveDialogTrigger, +} from '@/components/composites/ResponsiveDialog'; +import { Button, type buttonVariants } from '@/components/ui/Button'; import type { VariantProps } from '@/lib/utils'; import { useState } from 'react'; @@ -30,21 +30,23 @@ function ConfirmDialog({ confirmAction, t, ...props }: ConfirmDialogProps) { const [open, setOpen] = useState(false); return ( - - + + - + - - - + + + ); } diff --git a/src/components/composites/DatePicker.tsx b/src/components/composites/DatePicker.tsx new file mode 100644 index 0000000..4dbdb17 --- /dev/null +++ b/src/components/composites/DatePicker.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { isValid, parse } from 'date-fns'; +import { CalendarIcon } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { useFormatter } from 'next-intl'; +import * as React from 'react'; +import type { DayPickerProps } from 'react-day-picker'; + +import { cx } from '@/lib/utils'; + +import { Button } from '@/components/ui/Button'; +import { Calendar } from '@/components/ui/Calendar'; +import { Input } from '@/components/ui/Input'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/Popover'; + +type DatePickerProps = { + className?: string; + side?: 'top' | 'bottom' | 'left' | 'right'; + avoidCollisions?: boolean; + date: Date | undefined; + setDate: (date: Date | undefined) => void; + disabled?: boolean; +} & Omit< + DayPickerProps, + | 'fixedWeeks' + | 'today' + | 'selected' + | 'disabled' + | 'onSelect' + | 'autoFocus' + | 'mode' +> & + React.HTMLAttributes; + +/** + * This is a sligtly modified version of shadcn's Date Picker built on top of Calendar. + * The component has a state, but also allows adding an additional date callback function which + * provides a way to have side effects and/or state updates on the parent component whenever a new date is selected. + * UPDATE: Now supports an input field so it actually works as a date picker in a form. State is passed to it via props + * so it works in a form. Also included i18n support. + */ + +function DatePicker({ + className, + side, + avoidCollisions = true, + date, + setDate, + captionLayout, + footer, + hideWeekdays, + numberOfMonths, + showOutsideDays, + showWeekNumber, + disabled, + ...props +}: DatePickerProps) { + const t = useTranslations('ui'); + const format = useFormatter(); + const [open, setOpen] = React.useState(false); + const [month, setMonth] = React.useState(date); + const [inputValue, setInputValue] = React.useState(''); + + function handleSelectDate(date: Date | undefined) { + if (!date) { + setInputValue(''); + setDate(undefined); + } else { + setDate(date); + setMonth(date); + setInputValue( + format.dateTime(date, { + day: 'numeric', + month: 'numeric', + year: 'numeric', + }), + ); + } + } + + function handleInputChange(e: React.ChangeEvent) { + setInputValue(e.target.value); + const parsedDate = parse(e.target.value, t('dateFormat'), new Date()); + + if (isValid(parsedDate)) { + setMonth(parsedDate); + } + setDate(parsedDate); + } + + return ( + +
+ + + + +
+ + + +
+ ); +} + +export { DatePicker }; diff --git a/src/components/composites/PaginationCarousel.tsx b/src/components/composites/PaginationCarousel.tsx index 9ddd538..3457224 100644 --- a/src/components/composites/PaginationCarousel.tsx +++ b/src/components/composites/PaginationCarousel.tsx @@ -1,22 +1,126 @@ -import { PaginationCarouselClient } from '@/components/composites/PaginationCarouselClient'; +'use client'; + +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from '@/components/ui/Pagination'; +import { cx } from '@/lib/utils'; import { useTranslations } from 'next-intl'; +import { parseAsInteger, useQueryState } from 'nuqs'; + +type PaginationCarouselProps = { + className?: string; + totalPages: number; +}; function PaginationCarousel({ - ...props -}: { className?: string; totalPages: number }) { + className, + totalPages, +}: PaginationCarouselProps) { const t = useTranslations('ui'); + const [page, setPage] = useQueryState( + t('page'), + parseAsInteger.withDefault(1).withOptions({ shallow: false }), + ); + + function handlePrevious(e: React.MouseEvent) { + e.preventDefault(); + if (page > 1) { + void setPage(page - 1); + } + } + + function handleNext(e: React.MouseEvent) { + e.preventDefault(); + if (page < totalPages) { + void setPage(page + 1); + } + } + + function handlePageClick( + e: React.MouseEvent, + pageNum: number, + ) { + e.preventDefault(); + void setPage(pageNum); + } + + let pagesToDisplay = []; + if (page === 1) { + pagesToDisplay = [1, 2, 3].filter((pageNum) => pageNum <= totalPages); + } else if (page === totalPages) { + pagesToDisplay = [totalPages - 2, totalPages - 1, totalPages].filter( + (pageNum) => pageNum >= 1, + ); + } else { + pagesToDisplay = [page - 1, page, page + 1]; + } + + const lastPage = pagesToDisplay[pagesToDisplay.length - 1]; + return ( - + + + + + + {pagesToDisplay[0] !== undefined && pagesToDisplay[0] > 1 && ( + + + + )} + {pagesToDisplay.map( + (pageNum) => + pageNum > 0 && + pageNum <= totalPages && ( + + handlePageClick(e, pageNum)} + isActive={pageNum === page} + > + {pageNum} + + + ), + )} + {lastPage !== undefined && lastPage < totalPages && ( + + + + )} + + + + + ); } diff --git a/src/components/composites/PaginationCarouselClient.tsx b/src/components/composites/PaginationCarouselClient.tsx deleted file mode 100644 index 4693b24..0000000 --- a/src/components/composites/PaginationCarouselClient.tsx +++ /dev/null @@ -1,134 +0,0 @@ -'use client'; - -import { - Pagination, - PaginationContent, - PaginationEllipsis, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from '@/components/ui/Pagination'; -import { cx } from '@/lib/utils'; -import { parseAsInteger, useQueryState } from 'nuqs'; - -type PaginationCarouselProps = { - className?: string; - totalPages: number; - t: { - goToPreviousPage: string; - previous: string; - morePages: string; - goToNextPage: string; - next: string; - page: string; - }; -}; - -function PaginationCarouselClient({ - className, - totalPages, - t, -}: PaginationCarouselProps) { - const [page, setPage] = useQueryState( - t.page, - parseAsInteger.withDefault(1).withOptions({ shallow: false }), - ); - - function handlePrevious(e: React.MouseEvent) { - e.preventDefault(); - if (page > 1) { - void setPage(page - 1); - } - } - - function handleNext(e: React.MouseEvent) { - e.preventDefault(); - if (page < totalPages) { - void setPage(page + 1); - } - } - - function handlePageClick( - e: React.MouseEvent, - pageNum: number, - ) { - e.preventDefault(); - void setPage(pageNum); - } - - let pagesToDisplay = []; - if (page === 1) { - pagesToDisplay = [1, 2, 3].filter((pageNum) => pageNum <= totalPages); - } else if (page === totalPages) { - pagesToDisplay = [totalPages - 2, totalPages - 1, totalPages].filter( - (pageNum) => pageNum >= 1, - ); - } else { - pagesToDisplay = [page - 1, page, page + 1]; - } - - const lastPage = pagesToDisplay[pagesToDisplay.length - 1]; - - return ( - - - - - - {pagesToDisplay[0] !== undefined && pagesToDisplay[0] > 1 && ( - - - - )} - {pagesToDisplay.map( - (pageNum) => - pageNum > 0 && - pageNum <= totalPages && ( - - handlePageClick(e, pageNum)} - isActive={pageNum === page} - > - {pageNum} - - - ), - )} - {lastPage !== undefined && lastPage < totalPages && ( - - - - )} - - - - - - ); -} - -export { PaginationCarouselClient }; diff --git a/src/components/composites/ResponsiveDialog.tsx b/src/components/composites/ResponsiveDialog.tsx new file mode 100644 index 0000000..417fe57 --- /dev/null +++ b/src/components/composites/ResponsiveDialog.tsx @@ -0,0 +1,187 @@ +'use client'; + +import type * as React from 'react'; + +import { useMediaQuery } from '@/lib/hooks/useMediaQuery'; +import { cx } from '@/lib/utils'; + +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/Dialog'; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from '@/components/ui/Drawer'; + +interface BaseProps { + children: React.ReactNode; +} + +interface RootResponsiveDialogProps extends BaseProps { + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +interface ResponsiveDialogProps extends BaseProps { + className?: string; + asChild?: true; +} + +const desktop = '(min-width: 768px)'; + +/** + * This uses a drawer on mobile and a dialog on desktop so it is usually the preffered way to use dialogs in the app for a repsonsive experience. + */ +const ResponsiveDialog = ({ + children, + ...props +}: RootResponsiveDialogProps) => { + const isDesktop = useMediaQuery(desktop); + const ResponsiveDialog = isDesktop ? Dialog : Drawer; + + return {children}; +}; + +const ResponsiveDialogTrigger = ({ + className, + children, + ...props +}: ResponsiveDialogProps) => { + const isDesktop = useMediaQuery(desktop); + const ResponsiveDialogTrigger = isDesktop ? DialogTrigger : DrawerTrigger; + + return ( + + {children} + + ); +}; + +const ResponsiveDialogClose = ({ + className, + children, + ...props +}: ResponsiveDialogProps) => { + const isDesktop = useMediaQuery(desktop); + const ResponsiveDialogClose = isDesktop ? DialogClose : DrawerClose; + + return ( + + {children} + + ); +}; + +const ResponsiveDialogContent = ({ + className, + children, + ...props +}: ResponsiveDialogProps) => { + const isDesktop = useMediaQuery(desktop); + const ResponsiveDialogContent = isDesktop ? DialogContent : DrawerContent; + + return ( + + {children} + + ); +}; + +const ResponsiveDialogDescription = ({ + className, + children, + ...props +}: ResponsiveDialogProps) => { + const isDesktop = useMediaQuery(desktop); + const ResponsiveDialogDescription = isDesktop + ? DialogDescription + : DrawerDescription; + + return ( + + {children} + + ); +}; + +const ResponsiveDialogHeader = ({ + className, + children, + ...props +}: ResponsiveDialogProps) => { + const isDesktop = useMediaQuery(desktop); + const ResponsiveDialogHeader = isDesktop ? DialogHeader : DrawerHeader; + + return ( + + {children} + + ); +}; + +const ResponsiveDialogTitle = ({ + className, + children, + ...props +}: ResponsiveDialogProps) => { + const isDesktop = useMediaQuery(desktop); + const ResponsiveDialogTitle = isDesktop ? DialogTitle : DrawerTitle; + + return ( + + {children} + + ); +}; + +const ResponsiveDialogBody = ({ + className, + children, + ...props +}: ResponsiveDialogProps) => { + return ( +
+ {children} +
+ ); +}; + +const ResponsiveDialogFooter = ({ + className, + children, + ...props +}: ResponsiveDialogProps) => { + const isDesktop = useMediaQuery(desktop); + const ResponsiveDialogFooter = isDesktop ? DialogFooter : DrawerFooter; + + return ( + + {children} + + ); +}; + +export { + ResponsiveDialog, + ResponsiveDialogTrigger, + ResponsiveDialogClose, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogHeader, + ResponsiveDialogTitle, + ResponsiveDialogBody, + ResponsiveDialogFooter, +}; diff --git a/src/components/providers/IntlClientProvider.tsx b/src/components/providers/IntlClientProvider.tsx new file mode 100644 index 0000000..28c5577 --- /dev/null +++ b/src/components/providers/IntlClientProvider.tsx @@ -0,0 +1,20 @@ +import { NextIntlClientProvider, useMessages } from 'next-intl'; + +type Props = { + children: React.ReactNode; + locale: string; +}; + +function IntlClientProvider({ children, locale }: Props) { + const { ui, error } = useMessages(); + return ( + } + > + {children} + + ); +} + +export { IntlClientProvider }; diff --git a/src/components/providers/IntlErrorProvider.tsx b/src/components/providers/IntlErrorProvider.tsx deleted file mode 100644 index 197c9c3..0000000 --- a/src/components/providers/IntlErrorProvider.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { NextIntlClientProvider, useMessages } from 'next-intl'; - -type Props = { - children: React.ReactNode; - locale: string; -}; - -function IntlErrorProvider({ children, locale }: Props) { - const { error } = useMessages(); - return ( - - {children} - - ); -} - -export { IntlErrorProvider }; diff --git a/src/components/providers/NuqsProvider.tsx b/src/components/providers/NuqsProvider.tsx new file mode 100644 index 0000000..c8c6b59 --- /dev/null +++ b/src/components/providers/NuqsProvider.tsx @@ -0,0 +1,7 @@ +import { NuqsAdapter } from 'nuqs/adapters/next/app'; + +function NuqsProvider({ children }: { children: React.ReactNode }) { + return {children}; +} + +export { NuqsProvider }; diff --git a/src/components/providers/RootProviders.tsx b/src/components/providers/RootProviders.tsx index d4f5704..c87f384 100644 --- a/src/components/providers/RootProviders.tsx +++ b/src/components/providers/RootProviders.tsx @@ -1,4 +1,5 @@ -import { IntlErrorProvider } from '@/components/providers/IntlErrorProvider'; +import { IntlClientProvider } from '@/components/providers/IntlClientProvider'; +import { NuqsProvider } from '@/components/providers/NuqsProvider'; import { TRPCProvider } from '@/components/providers/TRPCProvider'; import { ThemeProvider } from '@/components/providers/ThemeProvider'; @@ -11,7 +12,9 @@ function RootProviders({ children, locale }: RootProvidersProps) { return ( - {children} + + {children} + ); diff --git a/src/components/providers/ThemeProvider.tsx b/src/components/providers/ThemeProvider.tsx index 39919b9..1b4753f 100644 --- a/src/components/providers/ThemeProvider.tsx +++ b/src/components/providers/ThemeProvider.tsx @@ -9,6 +9,10 @@ function ThemeProvider({ children }: { children: React.ReactNode }) { defaultTheme='system' enableSystem disableTransitionOnChange + themeColor={{ + light: 'hsl(0 0% 100%)', + dark: 'hsl(20 14.3% 4.1%)', + }} > {children} diff --git a/src/components/storage/AddToCartButton.tsx b/src/components/storage/AddToCartButton.tsx index c0009f7..aec7f2a 100644 --- a/src/components/storage/AddToCartButton.tsx +++ b/src/components/storage/AddToCartButton.tsx @@ -1,7 +1,7 @@ 'use client'; import { Button } from '@/components/ui/Button'; -import { Loader } from '@/components/ui/Loader'; +import { Spinner } from '@/components/ui/Spinner'; import { useLocalStorage } from '@/lib/hooks/useLocalStorage'; import { cx } from 'cva'; @@ -36,7 +36,7 @@ function AddToCartButton({ className, item, t }: AddToCartButtonProps) { ); if (isLoading) { - return ; + return ; } function updateCart() { diff --git a/src/components/storage/LoanForm.tsx b/src/components/storage/LoanForm.tsx index 81b26ac..9d77351 100644 --- a/src/components/storage/LoanForm.tsx +++ b/src/components/storage/LoanForm.tsx @@ -6,15 +6,13 @@ import { Form, FormControl, FormDescription, - FormField, FormItem, FormLabel, FormMessage, + useForm, } from '@/components/ui/Form'; import { Input } from '@/components/ui/Input'; -import { zodResolver } from '@hookform/resolvers/zod'; import { addDays, addWeeks, endOfWeek } from 'date-fns'; -import { useForm } from 'react-hook-form'; import { z } from 'zod'; const formSchema = z.object({ @@ -36,67 +34,68 @@ type LoanFormProps = { }; function LoanForm({ t }: LoanFormProps) { - const form = useForm>({ - resolver: zodResolver(formSchema), + const form = useForm(formSchema, { defaultValues: { phone: '', returnBy: new Date(), }, + onSubmit: ({ value }) => { + console.log(value); + }, }); - - function onSubmit(values: z.infer) { - // TODO: Add new loan to database - console.log(values); - } - return ( - <> -
- - ( - - {t.phoneNumber} - - - - {t.phoneNumberDescription} - - - )} - /> - ( - - {t.returnBy} - - - - {t.returnByDescription} - - - )} - /> - - - - + )} + + ); } diff --git a/src/components/ui/Calendar.tsx b/src/components/ui/Calendar.tsx index 905dbbc..47517e9 100644 --- a/src/components/ui/Calendar.tsx +++ b/src/components/ui/Calendar.tsx @@ -1,64 +1,208 @@ 'use client'; -import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; -import type * as React from 'react'; -import { DayPicker } from 'react-day-picker'; +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, + ChevronUpIcon, +} from 'lucide-react'; +import { useLocale } from 'next-intl'; +import { useFormatter, useTranslations } from 'next-intl'; +import { + DayPicker, + type DayPickerProps, + type DropdownOption, + type DropdownProps, +} from 'react-day-picker'; -import { buttonVariants } from '@/components/ui/Button'; +import { dayPickerLocales } from '@/lib/locale'; import { cx } from '@/lib/utils'; -export type CalendarProps = React.ComponentProps; +import { Button, buttonVariants } from '@/components/ui/Button'; +import { ScrollArea } from '@/components/ui/ScrollArea'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/Select'; + +export type CalendarProps = DayPickerProps; + +/** + * This component is also customised a lot from its shadcn counterpart. + * We have updated to use React Day Picker V9 (which has a lot of breaking changes). + * Our version supports a dropdown for the month and year if enabled via the captionLayout prop. + * Also it uses the correct locale labels for everything based on the current locale. + */ +function Dropdown({ + value, + onChange, + options, + formatSelectedOption, +}: DropdownProps & { + formatSelectedOption?: (option?: DropdownOption) => string | undefined; +}) { + const selectedOption = options?.find((option) => option.value === value); + + function handleChange(value: string) { + const changeEvent = { + target: { value }, + } as React.ChangeEvent; + onChange?.(changeEvent); + } + + return ( + + ); +} function Calendar({ className, classNames, - showOutsideDays = true, - locale, + showOutsideDays = false, ...props }: CalendarProps) { + const t = useTranslations('ui'); + const format = useFormatter(); + const currentLocale = useLocale(); return ( , - IconRight: ({ ...props }) => , + DayButton({ modifiers, className, ...buttonProps }) { + return ( + - - - handleDateChange(date)} - disabled={disabled} - /> - - - ); -} - -export { DatePicker }; diff --git a/src/components/ui/Drawer.tsx b/src/components/ui/Drawer.tsx new file mode 100644 index 0000000..ec06859 --- /dev/null +++ b/src/components/ui/Drawer.tsx @@ -0,0 +1,118 @@ +'use client'; + +import * as React from 'react'; +import { Drawer as DrawerPrimitive } from 'vaul'; + +import { cx } from '@/lib/utils'; + +const Drawer = ({ + shouldScaleBackground = true, + ...props +}: React.ComponentProps) => ( + +); +Drawer.displayName = 'Drawer'; + +const DrawerTrigger = DrawerPrimitive.Trigger; + +const DrawerPortal = DrawerPrimitive.Portal; + +const DrawerClose = DrawerPrimitive.Close; + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName; + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + +
+ {children} + + +)); +DrawerContent.displayName = 'DrawerContent'; + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DrawerHeader.displayName = 'DrawerHeader'; + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DrawerFooter.displayName = 'DrawerFooter'; + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerTitle.displayName = DrawerPrimitive.Title.displayName; + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DrawerDescription.displayName = DrawerPrimitive.Description.displayName; + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +}; diff --git a/src/components/ui/Form.tsx b/src/components/ui/Form.tsx index c945d9f..30a0ebb 100644 --- a/src/components/ui/Form.tsx +++ b/src/components/ui/Form.tsx @@ -2,153 +2,161 @@ import type * as LabelPrimitive from '@radix-ui/react-label'; import { Slot } from '@radix-ui/react-slot'; -import * as React from 'react'; import { - Controller, - type ControllerProps, - type FieldPath, - type FieldValues, - FormProvider, - useFormContext, -} from 'react-hook-form'; + type FormOptions, + type ValidationError, + type Validator, + useForm, +} from '@tanstack/react-form'; +import { zodValidator } from '@tanstack/zod-form-adapter'; +import * as React from 'react'; +import type { z } from 'zod'; -import { Label } from '@/components/ui/Label'; import { cx } from '@/lib/utils'; -const Form = FormProvider; +import { Label } from '@/components/ui/Label'; + +/** + * This is a completely custom component and not the Form component from shadcn. This is because we are using Tanstack Form instead of React Hook Form. + * Short explanation: Tanstack Form is a much better form library than React Hook Form. It has better validation, better performance, and better documentation. + * (The last sentence was ChatGPT's opinion, the real reason is that React hook form does not support async validation which is nice to have) + * On how to use this look where it has been used in the codebase or ask Michael. + */ +function useFormWithZod< + TFormSchema extends z.ZodType, + TFormData extends object = z.infer, +>( + schema: TFormSchema, + options?: Omit< + FormOptions>, + 'validatorAdapter' + >, +) { + const form = useForm({ + validatorAdapter: zodValidator(), + validators: { + onChange: schema, + }, + ...options, + }); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + e.stopPropagation(); + void form.handleSubmit(); + } -type FormFieldContextValue< - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, -> = { - name: TName; + return { + ...form, + handleSubmit, + }; +} + +const Form = ({ + className, + ...props +}: React.HTMLAttributes) => { + return
; }; -const FormFieldContext = React.createContext( - {} as FormFieldContextValue, +type FormItemContextValue = { + id: string; + errors: ValidationError[]; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue, ); -const FormField = < - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, ->({ +const FormItem = ({ + className, + errors, ...props -}: ControllerProps) => { +}: React.HTMLAttributes & { errors: ValidationError[] }) => { + const id = React.useId(); + return ( - - - + +
+ ); }; -const useFormField = () => { - const fieldContext = React.useContext(FormFieldContext); +const useFormItem = () => { const itemContext = React.useContext(FormItemContext); - const { getFieldState, formState } = useFormContext(); - const fieldState = getFieldState(fieldContext.name, formState); - - if (!fieldContext) { - throw new Error('useFormField should be used within '); + if (!itemContext) { + throw new Error('useFormField should be used within '); } - const { id } = itemContext; + const { id, errors } = itemContext; return { id, - name: fieldContext.name, + errors, formItemId: `${id}-form-item`, formDescriptionId: `${id}-form-item-description`, formMessageId: `${id}-form-item-message`, - ...fieldState, }; }; -type FormItemContextValue = { - id: string; -}; - -const FormItemContext = React.createContext( - {} as FormItemContextValue, -); - -const FormItem = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => { - const id = React.useId(); - - return ( - -
- - ); -}); -FormItem.displayName = 'FormItem'; - -const FormLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { error, formItemId } = useFormField(); +const FormLabel = ({ + className, + ...props +}: React.ComponentPropsWithoutRef) => { + const { formItemId, errors } = useFormItem(); return (