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}
+ {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')}`}
-
+
);
}
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 (
- <>
-
-
- >
+ )}
+
+
);
}
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 (
+
+ );
+ },
+ Chevron({ orientation, disabled, className }) {
+ const Component =
+ orientation === 'left'
+ ? ChevronLeftIcon
+ : orientation === 'right'
+ ? ChevronRightIcon
+ : orientation === 'up'
+ ? ChevronUpIcon
+ : ChevronDownIcon;
+
+ return (
+
+ );
+ },
+ YearsDropdown: Dropdown,
+ MonthsDropdown({ ...props }) {
+ return (
+ {
+ const month = option?.value ?? 0;
+ const year = new Date().getFullYear();
+ const date = new Date(year, month);
+ return format.dateTime(date, {
+ month: 'short',
+ });
+ }}
+ />
+ );
+ },
+ }}
+ showOutsideDays={showOutsideDays}
+ fixedWeeks
+ locale={dayPickerLocales[currentLocale as keyof typeof dayPickerLocales]}
+ labels={{
+ labelDayButton: (date, { today, selected }) => {
+ let label = format.dateTime(date, {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ });
+ if (today) label = `${t('today')}, ${label}`;
+ if (selected) label = `${label}, ${t('selected')}`;
+ return label;
+ },
+ labelWeekNumber: (weekNumber) => `${t('week')} ${weekNumber}`,
+ labelNext: () => t('nextMonth'),
+ labelPrevious: () => t('previousMonth'),
+ labelMonthDropdown: () => t('selectMonth'),
+ labelYearDropdown: () => t('selectYear'),
}}
- weekStartsOn={1}
{...props}
/>
);
diff --git a/src/components/ui/DatePicker.tsx b/src/components/ui/DatePicker.tsx
deleted file mode 100644
index ddd27de..0000000
--- a/src/components/ui/DatePicker.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-'use client';
-
-import { format } from 'date-fns';
-import { CalendarIcon } from 'lucide-react';
-import * as React from 'react';
-
-import { Button } from '@/components/ui/Button';
-import { Calendar } from '@/components/ui/Calendar';
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from '@/components/ui/Popover';
-import { cx } from '@/lib/utils';
-import type { Matcher } from 'react-day-picker';
-
-type DatePickerProps = {
- initialDate?: Date;
- dateCallback?: (date: Date) => void;
- disabled?: Matcher | Matcher[];
- buttonClassName?: string;
-};
-
-/**
- * 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.
- */
-function DatePicker({
- initialDate,
- dateCallback,
- disabled,
- buttonClassName,
-}: DatePickerProps) {
- const [date, setDate] = React.useState(initialDate ?? new Date());
-
- function handleDateChange(date: Date | undefined) {
- if (!date) return;
- setDate(date);
- if (dateCallback) {
- dateCallback(date);
- }
- }
-
- 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 (