diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 16f4c25..841aaa2 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -39,9 +39,14 @@ jobs: FEIDE_TOKEN_ENDPOINT: "https://auth.dataporten.no/oauth/token" FEIDE_USERINFO_ENDPOINT: "https://auth.dataporten.no/openid/userinfo" NEXT_PUBLIC_SITE_URL: "http://localhost:3000" + LHCI_TOKEN: ${{ secrets.LHCI_BUILD_TOKEN }} steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 20 + - name: Fetch base_ref HEAD + run: git fetch --depth=1 origin +refs/heads/${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }} - name: Setup bun uses: oven-sh/setup-bun@v2 with: @@ -54,5 +59,5 @@ jobs: env: LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} run: | - bun add -g @lhci/cli@0.13.x + bun add -g @lhci/cli@0.14.x lhci autorun diff --git a/.github/workflows/deploy-script.yml b/.github/workflows/deploy-script.yml index 38045f4..04ca6cf 100644 --- a/.github/workflows/deploy-script.yml +++ b/.github/workflows/deploy-script.yml @@ -25,7 +25,7 @@ on: jobs: script: name: Script - runs-on: ubuntu-latest + runs-on: self-hosted environment: ${{ inputs.environment }} steps: - uses: appleboy/ssh-action@v1.0.3 diff --git a/.gitignore b/.gitignore index b8a2932..aa2843f 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ public/robots.txt # data /data + +# Ignore husky files, see PR #54 +/.husky/* diff --git a/.husky/_/pre-commit b/.husky/_/pre-commit deleted file mode 100755 index 3fbf5f9..0000000 --- a/.husky/_/pre-commit +++ /dev/null @@ -1,60 +0,0 @@ -#!/bin/sh - -if [ "$LEFTHOOK_VERBOSE" = "1" -o "$LEFTHOOK_VERBOSE" = "true" ]; then - set -x -fi - -if [ "$LEFTHOOK" = "0" ]; then - exit 0 -fi - -call_lefthook() -{ - if test -n "$LEFTHOOK_BIN" - then - "$LEFTHOOK_BIN" "$@" - elif lefthook -h >/dev/null 2>&1 - then - lefthook "$@" - else - dir="$(git rev-parse --show-toplevel)" - osArch=$(uname | tr '[:upper:]' '[:lower:]') - cpuArch=$(uname -m | sed 's/aarch64/arm64/;s/x86_64/x64/') - if test -f "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook" - then - "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook" "$@" - elif test -f "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook" - then - "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook" "$@" - elif test -f "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook" - then - "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook" "$@" - elif test -f "$dir/node_modules/lefthook/bin/index.js" - then - "$dir/node_modules/lefthook/bin/index.js" "$@" - - elif bundle exec lefthook -h >/dev/null 2>&1 - then - bundle exec lefthook "$@" - elif yarn lefthook -h >/dev/null 2>&1 - then - yarn lefthook "$@" - elif pnpm lefthook -h >/dev/null 2>&1 - then - pnpm lefthook "$@" - elif swift package plugin lefthook >/dev/null 2>&1 - then - swift package --disable-sandbox plugin lefthook "$@" - elif command -v mint >/dev/null 2>&1 - then - mint run csjones/lefthook-plugin "$@" - elif command -v npx >/dev/null 2>&1 - then - npx lefthook "$@" - else - echo "Can't find lefthook in PATH" - fi - fi -} - -call_lefthook run "pre-commit" "$@" diff --git a/.husky/_/prepare-commit-msg b/.husky/_/prepare-commit-msg deleted file mode 100755 index e8e8dda..0000000 --- a/.husky/_/prepare-commit-msg +++ /dev/null @@ -1,60 +0,0 @@ -#!/bin/sh - -if [ "$LEFTHOOK_VERBOSE" = "1" -o "$LEFTHOOK_VERBOSE" = "true" ]; then - set -x -fi - -if [ "$LEFTHOOK" = "0" ]; then - exit 0 -fi - -call_lefthook() -{ - if test -n "$LEFTHOOK_BIN" - then - "$LEFTHOOK_BIN" "$@" - elif lefthook -h >/dev/null 2>&1 - then - lefthook "$@" - else - dir="$(git rev-parse --show-toplevel)" - osArch=$(uname | tr '[:upper:]' '[:lower:]') - cpuArch=$(uname -m | sed 's/aarch64/arm64/;s/x86_64/x64/') - if test -f "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook" - then - "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook" "$@" - elif test -f "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook" - then - "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook" "$@" - elif test -f "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook" - then - "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook" "$@" - elif test -f "$dir/node_modules/lefthook/bin/index.js" - then - "$dir/node_modules/lefthook/bin/index.js" "$@" - - elif bundle exec lefthook -h >/dev/null 2>&1 - then - bundle exec lefthook "$@" - elif yarn lefthook -h >/dev/null 2>&1 - then - yarn lefthook "$@" - elif pnpm lefthook -h >/dev/null 2>&1 - then - pnpm lefthook "$@" - elif swift package plugin lefthook >/dev/null 2>&1 - then - swift package --disable-sandbox plugin lefthook "$@" - elif command -v mint >/dev/null 2>&1 - then - mint run csjones/lefthook-plugin "$@" - elif command -v npx >/dev/null 2>&1 - then - npx lefthook "$@" - else - echo "Can't find lefthook in PATH" - fi - fi -} - -call_lefthook run "prepare-commit-msg" "$@" diff --git a/biome.json b/biome.json index 11d1c49..e8c5dd7 100644 --- a/biome.json +++ b/biome.json @@ -11,6 +11,9 @@ "enabled": true, "rules": { "recommended": true, + "a11y": { + "useSemanticElements": "off" + }, "nursery": { "useSortedClasses": { "level": "warn", diff --git a/bun.lockb b/bun.lockb index 327f808..68cb166 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/global.d.ts b/global.d.ts index 9ce5fc0..977f992 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,5 +1,3 @@ // Use type safe message keys with `next-intl` -// eslint-disable-next-line @typescript-eslint/consistent-type-imports type Messages = typeof import('./messages/en.json'); -// eslint-disable-next-line @typescript-eslint/no-empty-interface declare interface IntlMessages extends Messages {} diff --git a/lefthook.yml b/lefthook.yml index cf9b74e..b05d112 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,6 +1,6 @@ pre-commit: commands: check: - glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}" + glob: "*.{js,ts,tsx,json}" stage_fixed: true run: bunx @biomejs/biome check --write --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files} diff --git a/lighthouserc.cjs b/lighthouserc.cjs new file mode 100644 index 0000000..210334c --- /dev/null +++ b/lighthouserc.cjs @@ -0,0 +1,66 @@ +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 = { + ci: { + collect: { + url: [ + 'http://localhost:3000/en/', // Trailing slash required, else the regex for default lighthouse rules won't catch this one + 'http://localhost:3000/en/about', + 'http://localhost:3000/en/events', + 'http://localhost:3000/en/news', + 'http://localhost:3000/en/news/1', + 'http://localhost:3000/en/storage', + 'http://localhost:3000/en/storage/shopping-cart', + ], + startServerCommand: 'bun run start', + }, + upload: { + target: 'lhci', + serverBaseUrl: 'https://lhci.hackerspace-ntnu.no', // build token is set by the GH Action + }, + assert: { + assertMatrix: [ + { + matchingUrlPattern: `http://.*/en/(?!${PAGES_EXCLUDED.join('|')}).*`, // match all routes, except for pages with special rules. See https://github.com/GoogleChrome/lighthouse-ci/issues/511 and https://github.com/GoogleChrome/lighthouse-ci/issues/208#issuecomment-784501105 + preset: 'lighthouse:recommended', + assertions: { + 'bf-cache': 'off', + 'color-contrast': 'off', + 'heading-order': 'off', + 'largest-contentful-paint': 'off', + 'render-blocking-resources': 'off', + }, + }, + { + matchingUrlPattern: 'http://.*/en/news.*', + preset: 'lighthouse:recommended', + assertions: { + 'bf-cache': 'off', + 'color-contrast': 'off', + 'heading-order': 'off', + 'largest-contentful-paint': 'off', + 'render-blocking-resources': 'off', + interactive: 'off', + 'uses-responsive-images': 'off', // Should be removed when we obtain images from backend + }, + }, + { + matchingUrlPattern: 'http://.*/en/storage.*', + preset: 'lighthouse:recommended', + assertions: { + 'bf-cache': 'off', + 'color-contrast': 'off', + 'heading-order': 'off', + 'largest-contentful-paint': 'off', + 'render-blocking-resources': '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', + }, + }, + ], + }, + }, +}; diff --git a/lighthouserc.yml b/lighthouserc.yml deleted file mode 100644 index 8c8adb7..0000000 --- a/lighthouserc.yml +++ /dev/null @@ -1,37 +0,0 @@ -ci: - collect: - url: - - 'http://localhost:3000/en' - - 'http://localhost:3000/en/events' - - 'http://localhost:3000/en/news' - - 'http://localhost:3000/en/news/1' - - 'http://localhost:3000/en/about' - startServerCommand: 'bun run start' - upload: - target: 'temporary-public-storage' - assert: - preset: 'lighthouse:recommended' - assertions: - first-contentful-paint: - - error - - maxNumericValue: 2000 - aggregationMethod: optimistic - interactive: - - error - - maxNumericValue: 5000 - aggregationMethod: optimistic - bf-cache: 'off' - csp-xss: 'off' - identical-links-same-purpose: 'off' - total-byte-weight: 'off' - color-contrast: 'off' - heading-order: 'off' - mainthread-work-breakdown: 'off' - bootup-time: 'off' - largest-contentful-paint: 'off' - dom-size: 'off' - render-blocking-resources: 'off' - server-response-time: 'off' - uses-responsive-images: 'off' - maskable-icon: 'off' - installable-manifest: 'off' diff --git a/messages/en.json b/messages/en.json index 316e4ba..5c0c922 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9,7 +9,18 @@ "next": "Next", "goToNextPage": "Go to next page", "morePages": "More pages", - "page": "page" + "page": "page", + "category": "category", + "sort": "sort", + "photoOf": "Photo of {name}" + }, + "error": { + "notFound": "404 - Page not found", + "notFoundDescription": "Oops! Looks like this page got lost in cyberspace.", + "error": "Oops! Something went wrong", + "errorDescription": "Don't worry, our best hackers are on it!", + "goToHomepage": "Return to homepage", + "tryAgain": "Try again" }, "layout": { "hackerspaceHome": "Hackerspace homepage", @@ -52,13 +63,15 @@ }, "storage": { "title": "Storage", + "searchPlaceholder": "Search for product...", "card": { "quantityInfo": "{quantity} units", - "addToCart": "Add to cart" + "addToCart": "Add to cart", + "removeFromCart": "Remove from cart" }, "select": { + "ariaLabel": "Select how to filter the storage items", "filters": "Filters", - "defaultPlaceholder": "Sort results", "popularity": "Popularity", "sortDescending": "Inventory (descending)", "sortAscending": "Inventory (ascending)", @@ -72,8 +85,43 @@ "peripherals": "PC peripherals", "miniPC": "Mini PC" }, + "searchParams": { + "popularity": "popularity", + "descending": "descending", + "ascending": "ascending", + "name": "name", + "cables": "cables", + "sensors": "sensors", + "peripherals": "peripherals", + "miniPC": "minipc" + }, "tooltips": { "viewShoppingCart": "View shopping cart" + }, + "shoppingCart": { + "title": "Shopping Cart", + "productId": "Product ID", + "productName": "Product Name", + "location": "Location", + "unitsAvailable": "Units available", + "tableDescription": "A list of your shopping cart items.", + "backToStorage": "Back to storage", + "cartEmpty": "Your shopping cart is empty.", + "clearCart": "Empty shopping cart", + "cancel": "Cancel", + "clear": "Clear", + "clearCartDescription": "Are you sure you want to clear your shopping cart? All items will be removed.", + "borrowNow": "Borrow now", + "amountOfItemARIA": "Select number of this item" + }, + "loanForm": { + "name": "Name", + "email": "Email", + "phoneNumber": "Phone number", + "phoneNumberDescription": "Phone number for contact. Include country code if the number isn't Norwegian.", + "returnBy": "Return by", + "returnByDescription": "Select how long you would like to borrow the item for.", + "submit": "Submit" } } } diff --git a/messages/no.json b/messages/no.json index 79286cb..62a6fd0 100644 --- a/messages/no.json +++ b/messages/no.json @@ -9,7 +9,18 @@ "next": "Neste", "goToNextPage": "Gå til neste side", "morePages": "Flere sider", - "page": "side" + "page": "side", + "category": "kategori", + "sort": "sortering", + "photoOf": "Bilde av {name}" + }, + "error": { + "notFound": "404 - Siden ble ikke funnet", + "notFoundDescription": "Oops! Ser ut som denne siden gikk seg vill i cyberspace.", + "error": "Oops! Noe gikk galt", + "errorDescription": "Ikke bekymre deg, våre beste hackere jobber med saken!", + "goToHomepage": "Gå tilbake til hjemmesiden", + "tryAgain": "Prøv igjen" }, "layout": { "hackerspaceHome": "Hackerspace hjemmeside", @@ -52,13 +63,15 @@ }, "storage": { "title": "Lager", + "searchPlaceholder": "Søk etter produkt...", "card": { "quantityInfo": "{quantity} stk.", - "addToCart": "Legg i handlekurven" + "addToCart": "Legg i handlekurven", + "removeFromCart": "Fjern fra handlekurven" }, "select": { + "ariaLabel": "Velg hvordan du vil filtrere varene i lageret", "filters": "Filtre", - "defaultPlaceholder": "Sorter resultater", "popularity": "Popularitet", "sortDescending": "Lagerbeholdning (synkende)", "sortAscending": "Lagerbeholdning (stigende)", @@ -72,8 +85,43 @@ "peripherals": "PC-tilbehør", "miniPC": "Mini-PC" }, + "searchParams": { + "popularity": "popularitet", + "descending": "synkende", + "ascending": "stigende", + "name": "navn", + "cables": "kabler", + "sensors": "sensorer", + "peripherals": "tilbehoer", + "miniPC": "minipc" + }, "tooltips": { "viewShoppingCart": "Vis handlekurv" + }, + "shoppingCart": { + "title": "Handlekurv", + "productId": "Produkt-ID", + "productName": "Produktnavn", + "location": "Plass", + "unitsAvailable": "Stk tilgjengelig", + "tableDescription": "En liste over handlekurven din.", + "backToStorage": "Tilbake til lageret", + "cartEmpty": "Handlekurven din er tom.", + "clearCart": "Tøm handlekurven", + "cancel": "Avbryt", + "clear": "Tøm", + "clearCartDescription": "Er du sikker på at du vil tømme handlekurven? Alle varer vil bli slettet.", + "borrowNow": "Lån nå", + "amountOfItemARIA": "Velg antallet av denne gjenstanden" + }, + "loanForm": { + "name": "Navn", + "email": "Epost", + "phoneNumber": "Mobilnummer", + "phoneNumberDescription": "Mobilnummer for kontakt. Inkluder landskode hvis mobilnummeret er ikke norsk.", + "returnBy": "Lån fram til", + "returnByDescription": "Velg hvor lenge du ønsker å låne gjenstanden(e)", + "submit": "Send" } } } diff --git a/next.config.js b/next.config.js index 69a418a..819aad4 100644 --- a/next.config.js +++ b/next.config.js @@ -1,11 +1,7 @@ import nextIntl from 'next-intl/plugin'; - -/** - * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially useful - * for Docker builds. - */ await import('./src/env.js'); -const withNextIntl = nextIntl('./src/lib/locale/i18n.ts'); + +const withNextIntl = nextIntl('./src/lib/locale/request.ts'); /** @type {import("next").NextConfig} */ const config = { diff --git a/package.json b/package.json index 4b7093b..43e37e9 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "prepare": "if [ \"$NODE_ENV\" != \"production\" ]; then lefthook install; fi", - "dev": "next dev --turbo", + "dev": "next dev", "lint": "biome check --write", "prebuild": "next telemetry disable", "build": "next build", @@ -20,10 +20,12 @@ }, "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-select": "^2.1.1", "@radix-ui/react-separator": "^1.1.0", @@ -34,42 +36,46 @@ "@trpc/client": "^11.0.0-rc.490", "@trpc/react-query": "^11.0.0-rc.490", "@trpc/server": "^11.0.0-rc.490", - "autoprefixer": "^10.4.19", - "client-only": "^0.0.1", "cmdk": "1.0.0", "country-flag-icons": "^1.5.12", "cva": "^1.0.0-beta.1", + "date-fns": "^4.1.0", "drizzle-orm": "^0.33.0", "lucia": "^3.2.0", "lucide-react": "^0.396.0", "next": "^14.2.10", "next-intl": "^3.18.1", - "next-sitemap": "^4.2.3", "next-themes": "^0.3.0", "nuqs": "^1.17.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", "reading-time": "^1.5.0", - "server-only": "^0.0.1", "sharp": "^0.33.4", "superjson": "^2.2.1", "tailwind-merge": "^2.5.2", "zod": "^3.23.8" }, "devDependencies": { - "@biomejs/biome": "1.8.3", + "@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", + "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", "tailwindcss": "^3.4.4", "tailwindcss-animate": "^1.0.7", + "tailwindcss-radix": "^3.0.5", "typescript": "^5.5.0" }, "packageManager": "bun@1.1.12" diff --git a/postcss.config.cjs b/postcss.config.js similarity index 52% rename from postcss.config.cjs rename to postcss.config.js index e305dd9..2ef30fc 100644 --- a/postcss.config.cjs +++ b/postcss.config.js @@ -1,3 +1,4 @@ +/** @type {import('postcss-load-config').Config} */ const config = { plugins: { tailwindcss: {}, @@ -5,4 +6,4 @@ const config = { }, }; -module.exports = config; +export default config; diff --git a/src/app/[locale]/(default)/news/(header)/layout.tsx b/src/app/[locale]/(default)/news/(main)/layout.tsx similarity index 100% rename from src/app/[locale]/(default)/news/(header)/layout.tsx rename to src/app/[locale]/(default)/news/(main)/layout.tsx diff --git a/src/app/[locale]/(default)/news/(header)/loading.tsx b/src/app/[locale]/(default)/news/(main)/loading.tsx similarity index 81% rename from src/app/[locale]/(default)/news/(header)/loading.tsx rename to src/app/[locale]/(default)/news/(main)/loading.tsx index 0883f7d..8d26a19 100644 --- a/src/app/[locale]/(default)/news/(header)/loading.tsx +++ b/src/app/[locale]/(default)/news/(main)/loading.tsx @@ -1,4 +1,4 @@ -import { PaginationCarouselSkeleton } from '@/components/layout/PaginationCarouselSkeleton'; +import { PaginationCarouselSkeleton } from '@/components/composites/PaginationCarouselSkeleton'; import { CardGridSkeleton } from '@/components/news/CardGridSkeleton'; import { ItemGridSkeleton } from '@/components/news/ItemGridSkeleton'; import { Separator } from '@/components/ui/Separator'; diff --git a/src/app/[locale]/(default)/news/(header)/page.tsx b/src/app/[locale]/(default)/news/(main)/page.tsx similarity index 82% rename from src/app/[locale]/(default)/news/(header)/page.tsx rename to src/app/[locale]/(default)/news/(main)/page.tsx index 30444ab..a7bfa8d 100644 --- a/src/app/[locale]/(default)/news/(header)/page.tsx +++ b/src/app/[locale]/(default)/news/(main)/page.tsx @@ -4,7 +4,7 @@ import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; import { createSearchParamsCache, parseAsInteger } from 'nuqs/server'; import { Suspense } from 'react'; -import { PaginationCarousel } from '@/components/layout/PaginationCarousel'; +import { PaginationCarousel } from '@/components/composites/PaginationCarousel'; import { CardGrid } from '@/components/news/CardGrid'; import { ItemGrid } from '@/components/news/ItemGrid'; import { ItemGridSkeleton } from '@/components/news/ItemGridSkeleton'; @@ -45,16 +45,8 @@ export default function NewsPage({ ); diff --git a/src/app/[locale]/(default)/news/[article]/page.tsx b/src/app/[locale]/(default)/news/[article]/page.tsx index c4f6e37..a9b96f3 100644 --- a/src/app/[locale]/(default)/news/[article]/page.tsx +++ b/src/app/[locale]/(default)/news/[article]/page.tsx @@ -55,7 +55,7 @@ export default function ArticlePage({ return (
-
+
{`${article.views} ${t('views')}`} -
{article.content}
+
{article.content}
); } diff --git a/src/app/[locale]/(default)/storage/(main)/layout.tsx b/src/app/[locale]/(default)/storage/(main)/layout.tsx new file mode 100644 index 0000000..4bddd7a --- /dev/null +++ b/src/app/[locale]/(default)/storage/(main)/layout.tsx @@ -0,0 +1,87 @@ +import { CategorySelector } from '@/components/composites/CategorySelector'; +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 { Suspense } from 'react'; + +type StorageLayoutProps = { + children: React.ReactNode; + params: { locale: string }; +}; + +export default function StorageLayout({ + children, + params: { locale }, +}: StorageLayoutProps) { + unstable_setRequestLocale(locale); + const t = useTranslations('storage'); + const tUi = useTranslations('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 = [ + { + label: t('combobox.cables'), + value: t('searchParams.cables'), + }, + { + label: t('combobox.sensors'), + value: t('searchParams.sensors'), + }, + { + label: t('combobox.peripherals'), + value: t('searchParams.peripherals'), + }, + { + label: t('combobox.miniPC'), + value: t('searchParams.miniPC'), + }, + ]; + + const filters = [ + { name: t('select.popularity'), urlName: t('searchParams.popularity') }, + { name: t('select.sortDescending'), urlName: t('searchParams.descending') }, + { name: t('select.sortAscending'), urlName: t('searchParams.ascending') }, + { name: t('select.name'), urlName: t('searchParams.name') }, + ]; + + return ( + <> +
+

{t('title')}

+ +
+
+ + }> + + + +
+ {children} + + ); +} diff --git a/src/app/[locale]/(default)/storage/(main)/loading.tsx b/src/app/[locale]/(default)/storage/(main)/loading.tsx new file mode 100644 index 0000000..c510d4f --- /dev/null +++ b/src/app/[locale]/(default)/storage/(main)/loading.tsx @@ -0,0 +1,16 @@ +import { PaginationCarouselSkeleton } from '@/components/composites/PaginationCarouselSkeleton'; +import { ItemCardSkeleton } from '@/components/storage/ItemCardSkeleton'; +import { useId } from 'react'; + +export default function StorageSkeleton() { + return ( + <> +
+ {Array.from({ length: 8 }).map(() => ( + + ))} +
+ + + ); +} diff --git a/src/app/[locale]/(default)/storage/(main)/page.tsx b/src/app/[locale]/(default)/storage/(main)/page.tsx new file mode 100644 index 0000000..9bbb49a --- /dev/null +++ b/src/app/[locale]/(default)/storage/(main)/page.tsx @@ -0,0 +1,54 @@ +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 { PaginationCarousel } from '@/components/composites/PaginationCarousel'; +import { ItemCard } from '@/components/storage/ItemCard'; + +export async function generateMetadata({ + params: { locale }, +}: { + params: { locale: string }; +}) { + const t = await getTranslations({ locale, namespace: 'layout' }); + + return { + title: t('storage'), + }; +} + +export default function StoragePage({ + params: { locale }, + searchParams, +}: { + params: { locale: string }; + searchParams: Record; +}) { + unstable_setRequestLocale(locale); + const t = useTranslations('ui'); + + const itemsPerPage = 12; + + const searchParamsCache = createSearchParamsCache({ + [t('page')]: parseAsInteger.withDefault(1), + }); + + const { [t('page')]: page = 1 } = searchParamsCache.parse(searchParams); + + return ( + <> +
+ {items + .slice((page - 1) * itemsPerPage, page * itemsPerPage) + .map((item) => ( + + ))} +
+ + + ); +} diff --git a/src/app/[locale]/(default)/storage/layout.tsx b/src/app/[locale]/(default)/storage/layout.tsx deleted file mode 100644 index 0a81eae..0000000 --- a/src/app/[locale]/(default)/storage/layout.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Button } from '@/components/ui/Button'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/Tooltip'; -import { ShoppingCartIcon } from 'lucide-react'; -import { useTranslations } from 'next-intl'; - -export default function StorageLayout({ - children, -}: { - children: React.ReactNode; -}) { - const t = useTranslations('storage'); - - return ( - <> -
-

{t('title')}

- - - - - - -

{t('tooltips.viewShoppingCart')}

-
-
-
-
- {children} - - ); -} diff --git a/src/app/[locale]/(default)/storage/loading.tsx b/src/app/[locale]/(default)/storage/loading.tsx deleted file mode 100644 index f88b271..0000000 --- a/src/app/[locale]/(default)/storage/loading.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { SkeletonCard } from '@/components/storage/SkeletonCard'; -import { Skeleton } from '@/components/ui/Skeleton'; - -export default function StorageSkeleton() { - return ( - <> -
- - - -
-
- - - - - - - - -
- - ); -} diff --git a/src/app/[locale]/(default)/storage/new/page.tsx b/src/app/[locale]/(default)/storage/new/page.tsx new file mode 100644 index 0000000..6ec7220 --- /dev/null +++ b/src/app/[locale]/(default)/storage/new/page.tsx @@ -0,0 +1,3 @@ +export default function NewItemPage() { + return

New item page

; +} diff --git a/src/app/[locale]/(default)/storage/page.tsx b/src/app/[locale]/(default)/storage/page.tsx deleted file mode 100644 index 223f604..0000000 --- a/src/app/[locale]/(default)/storage/page.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { PaginationCarousel } from '@/components/layout/PaginationCarousel'; -import { Button } from '@/components/ui/Button'; -import { - Card, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '@/components/ui/Card'; -import { Combobox } from '@/components/ui/Combobox'; -import { SearchBar } from '@/components/ui/SearchBar'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/Select'; -import { items } from '@/mock-data/items'; -import { useTranslations } from 'next-intl'; -import { getTranslations, unstable_setRequestLocale } from 'next-intl/server'; -import Image from 'next/image'; -import { createSearchParamsCache, parseAsInteger } from 'nuqs/server'; - -export async function generateMetadata({ - params: { locale }, -}: { - params: { locale: string }; -}) { - const t = await getTranslations({ locale, namespace: 'layout' }); - - return { - title: t('storage'), - }; -} - -export default function StoragePage({ - params: { locale }, - searchParams, -}: { - params: { locale: string }; - searchParams: Record; -}) { - unstable_setRequestLocale(locale); - const t = useTranslations('storage'); - const t_ui = useTranslations('ui'); - - const itemsPerPage = 12; - - const searchParamsCache = createSearchParamsCache({ - [t_ui('page')]: parseAsInteger.withDefault(1), - }); - - const { [t_ui('page')]: page = 1 } = searchParamsCache.parse(searchParams); - - // TODO: Implement filters and category selection - const categories = [ - { - value: 'cables', - label: t('combobox.cables'), - }, - { - value: 'sensors', - label: t('combobox.sensors'), - }, - { - value: 'peripherals', - label: t('combobox.peripherals'), - }, - { - value: 'miniPC', - label: t('combobox.miniPC'), - }, - ]; - - const filters = [ - 'select.popularity', - 'select.sortDescending', - 'select.sortAscending', - 'select.name', - ] as const; - - return ( - <> -
- - - - -
-
- {items - .slice((page - 1) * itemsPerPage, page * itemsPerPage) - .map((item) => ( - - -
- {`Photo -
- {item.name} - - {item.location} - -
- - - {t('card.quantityInfo', { quantity: item.quantity })} - - - -
- ))} -
- - - ); -} diff --git a/src/app/[locale]/(default)/storage/shopping-cart/layout.tsx b/src/app/[locale]/(default)/storage/shopping-cart/layout.tsx new file mode 100644 index 0000000..8235cb2 --- /dev/null +++ b/src/app/[locale]/(default)/storage/shopping-cart/layout.tsx @@ -0,0 +1,38 @@ +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'; + +type ShoppingCartLayoutProps = { + children: React.ReactNode; + params: { locale: string }; +}; + +export default function StorageLayout({ + children, + params: { locale }, +}: ShoppingCartLayoutProps) { + unstable_setRequestLocale(locale); + const t = useTranslations('storage.shoppingCart'); + return ( + <> +
+

+ {t('title')} +

+ +
+ {children} + + ); +} diff --git a/src/app/[locale]/(default)/storage/shopping-cart/loading.tsx b/src/app/[locale]/(default)/storage/shopping-cart/loading.tsx new file mode 100644 index 0000000..27310cd --- /dev/null +++ b/src/app/[locale]/(default)/storage/shopping-cart/loading.tsx @@ -0,0 +1,23 @@ +import { ShoppingCartTableSkeleton } from '@/components/storage/ShoppingCartTableSkeleton'; +import { Skeleton } from '@/components/ui/Skeleton'; +import { useTranslations } from 'next-intl'; + +export default function ShoppingCartSkeleton() { + const t = useTranslations('storage.shoppingCart'); + const tableMessages = { + productId: t('productId'), + productName: t('productName'), + location: t('location'), + unitsAvailable: t('unitsAvailable'), + }; + + return ( + <> + +
+ + +
+ + ); +} diff --git a/src/app/[locale]/(default)/storage/shopping-cart/page.tsx b/src/app/[locale]/(default)/storage/shopping-cart/page.tsx new file mode 100644 index 0000000..f72474e --- /dev/null +++ b/src/app/[locale]/(default)/storage/shopping-cart/page.tsx @@ -0,0 +1,54 @@ +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'; + +export default function StorageShoppingCartPage({ + params: { locale }, +}: { + params: { locale: string }; +}) { + unstable_setRequestLocale(locale); + const t = useTranslations('storage.shoppingCart'); + const tLoanForm = useTranslations('storage.loanForm'); + + const tableMessages = { + tableDescription: t('tableDescription'), + productId: t('productId'), + productName: t('productName'), + location: t('location'), + unitsAvailable: t('unitsAvailable'), + cartEmpty: t('cartEmpty'), + amountOfItemARIA: t('amountOfItemARIA'), + }; + + const borrowNowMessages = { + borrowNow: t('borrowNow'), + name: tLoanForm('name'), + email: tLoanForm('email'), + phoneNumber: tLoanForm('phoneNumber'), + phoneNumberDescription: tLoanForm('phoneNumberDescription'), + returnBy: tLoanForm('returnBy'), + returnByDescription: tLoanForm('returnByDescription'), + submit: tLoanForm('submit'), + }; + + return ( + <> + +
+ + +
+ + ); +} diff --git a/src/app/[locale]/error.tsx b/src/app/[locale]/error.tsx new file mode 100644 index 0000000..a839b0a --- /dev/null +++ b/src/app/[locale]/error.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { Button } from '@/components/ui/Button'; +import { Link } from '@/lib/locale/navigation'; +import { AlertTriangleIcon } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { useEffect } from 'react'; + +export default function ErrorPage({ + error, + reset, +}: { + error: Error; + reset: () => void; +}) { + const t = useTranslations('error'); + useEffect(() => { + console.error(error); + }, [error]); + return ( +
+ +

+ {t('error')} +

+

+ {t('errorDescription')} +

+ {error.message && ( +

+ Error: {error.message} +

+ )} +
+ + +
+
+ ); +} diff --git a/src/app/[locale]/not-found.tsx b/src/app/[locale]/not-found.tsx new file mode 100644 index 0000000..e97706f --- /dev/null +++ b/src/app/[locale]/not-found.tsx @@ -0,0 +1,22 @@ +import { Button } from '@/components/ui/Button'; +import { Link } from '@/lib/locale/navigation'; +import { HardDriveIcon } from 'lucide-react'; +import { useTranslations } from 'next-intl'; + +export default function NotFoundPage() { + const t = useTranslations('error'); + return ( +
+ +

+ {t('notFound')} +

+

+ {t('notFoundDescription')} +

+ +
+ ); +} diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 3f80952..17d77be 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,9 +1,14 @@ 'use client'; import { routing } from '@/lib/locale'; -import { redirect, usePathname } from 'next/navigation'; +import NextError from 'next/error'; -export default function NotFound() { - const pathname = usePathname(); - redirect(`/${routing.defaultLocale}/${pathname}`); +export default function NotFoundPage() { + return ( + + + + + + ); } diff --git a/src/components/composites/CategorySelector.tsx b/src/components/composites/CategorySelector.tsx new file mode 100644 index 0000000..260cec6 --- /dev/null +++ b/src/components/composites/CategorySelector.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useQueryState } from 'nuqs'; +import { parseAsString } from 'nuqs/server'; +import { Combobox } from '../ui/Combobox'; + +type CategorySelectorProps = { + categories: { + value: string; + label: string; + }[]; + t: { + category: string; + sort: string; + defaultDescription: string; + defaultPlaceholder: string; + }; +}; + +function CategorySelector({ categories, t }: CategorySelectorProps) { + const [category, setCategory] = useQueryState( + t.category, + parseAsString + .withDefault('') + .withOptions({ shallow: false, clearOnDefault: true }), + ); + + function valueCallback(category: string | null) { + setCategory(category); + } + + return ( + + ); +} + +export { CategorySelector }; diff --git a/src/components/composites/ConfirmDialog.tsx b/src/components/composites/ConfirmDialog.tsx new file mode 100644 index 0000000..4a2c1b3 --- /dev/null +++ b/src/components/composites/ConfirmDialog.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { Button, type buttonVariants } from '@/components/ui/Button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/Dialog'; +import type { VariantProps } from '@/lib/utils'; +import { useState } from 'react'; + +type ConfirmDialogProps = { + t: { + title: string; + description: string; + cancel: string; + confirm: string; + }; + confirmAction: () => void; + disabled?: boolean; +} & React.HTMLAttributes & + VariantProps; + +function ConfirmDialog({ confirmAction, t, ...props }: ConfirmDialogProps) { + const [open, setOpen] = useState(false); + + return ( + + + + + + + + + ); +} + +export { ConfirmDialog }; diff --git a/src/components/composites/PaginationCarousel.tsx b/src/components/composites/PaginationCarousel.tsx new file mode 100644 index 0000000..9ddd538 --- /dev/null +++ b/src/components/composites/PaginationCarousel.tsx @@ -0,0 +1,23 @@ +import { PaginationCarouselClient } from '@/components/composites/PaginationCarouselClient'; +import { useTranslations } from 'next-intl'; + +function PaginationCarousel({ + ...props +}: { className?: string; totalPages: number }) { + const t = useTranslations('ui'); + return ( + + ); +} + +export { PaginationCarousel }; diff --git a/src/components/layout/PaginationCarousel.tsx b/src/components/composites/PaginationCarouselClient.tsx similarity index 97% rename from src/components/layout/PaginationCarousel.tsx rename to src/components/composites/PaginationCarouselClient.tsx index bdc222c..4693b24 100644 --- a/src/components/layout/PaginationCarousel.tsx +++ b/src/components/composites/PaginationCarouselClient.tsx @@ -11,6 +11,7 @@ import { } from '@/components/ui/Pagination'; import { cx } from '@/lib/utils'; import { parseAsInteger, useQueryState } from 'nuqs'; + type PaginationCarouselProps = { className?: string; totalPages: number; @@ -24,7 +25,7 @@ type PaginationCarouselProps = { }; }; -function PaginationCarousel({ +function PaginationCarouselClient({ className, totalPages, t, @@ -130,4 +131,4 @@ function PaginationCarousel({ ); } -export { PaginationCarousel }; +export { PaginationCarouselClient }; diff --git a/src/components/layout/PaginationCarouselSkeleton.tsx b/src/components/composites/PaginationCarouselSkeleton.tsx similarity index 93% rename from src/components/layout/PaginationCarouselSkeleton.tsx rename to src/components/composites/PaginationCarouselSkeleton.tsx index 9e55181..1f62e88 100644 --- a/src/components/layout/PaginationCarouselSkeleton.tsx +++ b/src/components/composites/PaginationCarouselSkeleton.tsx @@ -7,6 +7,8 @@ import { PaginationPrevious, } from '@/components/ui/Pagination'; import { useTranslations } from 'next-intl'; +import { useId } from 'react'; + type PaginationCarouselSkeletonProps = { className?: string; }; @@ -28,10 +30,10 @@ function PaginationCarouselSkeleton({ tabIndex={-1} /> - {Array.from({ length: 4 }).map((_, index) => ( + {Array.from({ length: 4 }).map(() => ( diff --git a/src/components/composites/SearchBar.tsx b/src/components/composites/SearchBar.tsx new file mode 100644 index 0000000..38b8bf0 --- /dev/null +++ b/src/components/composites/SearchBar.tsx @@ -0,0 +1,20 @@ +import { Input } from '@/components/ui/Input'; +import { cx } from '@/lib/utils'; +import { SearchIcon } from 'lucide-react'; +import * as React from 'react'; + +type SearchBarProps = React.InputHTMLAttributes; + +const SearchBar = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +
+ + +
+ ); + }, +); +SearchBar.displayName = 'SearchBar'; + +export { SearchBar }; diff --git a/src/components/composites/SortSelector.tsx b/src/components/composites/SortSelector.tsx new file mode 100644 index 0000000..2adc1db --- /dev/null +++ b/src/components/composites/SortSelector.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/Select'; +import { parseAsString, useQueryState } from 'nuqs'; + +type SortSelectorProps = { + filters: { + name: string; + urlName: string; + }[]; + t: { + sort: string; + defaultValue: string; + defaultSorting: string; + ariaLabel: string; + }; +}; + +function SortSelector({ filters, t }: SortSelectorProps) { + const [filter, setFilter] = useQueryState( + t.sort, + parseAsString + .withDefault(t.defaultSorting) + .withOptions({ shallow: false, clearOnDefault: true }), + ); + + return ( + + ); +} +export { SortSelector }; diff --git a/src/components/home/HelloWorld.tsx b/src/components/home/HelloWorld.tsx index 4be51a8..3506c1e 100644 --- a/src/components/home/HelloWorld.tsx +++ b/src/components/home/HelloWorld.tsx @@ -1,4 +1,5 @@ 'use client'; + import { api } from '@/lib/api/client'; function HelloWorld() { diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index 0c83b28..cc7f462 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -6,7 +6,7 @@ import { } from '@/components/assets/icons'; import { IDILogo, NexusLogo } from '@/components/assets/sponsors'; import { LogoLink } from '@/components/layout/LogoLink'; -import { Nav } from '@/components/layout/Nav'; +import { Nav } from '@/components/layout/header/Nav'; import { Button } from '@/components/ui/Button'; import { Link } from '@/lib/locale/navigation'; import { BugIcon, MailIcon } from 'lucide-react'; diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index ebae02f..85faf28 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,26 +1,28 @@ import { LogoLink } from '@/components/layout/LogoLink'; -import { MobileSheet } from '@/components/layout/MobileSheet'; -import { Nav } from '@/components/layout/Nav'; -import { DarkModeMenu } from '@/components/settings/DarkModeMenu'; -import { LocaleMenu } from '@/components/settings/LocaleMenu'; -import { ProfileMenu } from '@/components/settings/ProfileMenu'; +import { DarkModeMenu } from '@/components/layout/header/DarkModeMenu'; +import { LocaleMenu } from '@/components/layout/header/LocaleMenu'; +import { MobileSheet } from '@/components/layout/header/MobileSheet'; +import { Nav } from '@/components/layout/header/Nav'; +import { ProfileMenu } from '@/components/layout/header/ProfileMenu'; import { useTranslations } from 'next-intl'; function Header() { const t = useTranslations('layout'); return (
- - +
+ + +
diff --git a/src/components/news/ItemGridSkeleton.tsx b/src/components/news/ItemGridSkeleton.tsx index 51f4699..52db860 100644 --- a/src/components/news/ItemGridSkeleton.tsx +++ b/src/components/news/ItemGridSkeleton.tsx @@ -1,11 +1,11 @@ import { ArticleItemSkeleton } from '@/components/news/ArticleItemSkeleton'; -import * as React from 'react'; +import { useId } from 'react'; function ItemGridSkeleton() { return (
- {Array.from({ length: 6 }).map((_, index) => ( - + {Array.from({ length: 6 }).map(() => ( + ))}
); diff --git a/src/components/providers/IntlErrorProvider.tsx b/src/components/providers/IntlErrorProvider.tsx index afead1f..197c9c3 100644 --- a/src/components/providers/IntlErrorProvider.tsx +++ b/src/components/providers/IntlErrorProvider.tsx @@ -6,12 +6,9 @@ type Props = { }; function IntlErrorProvider({ children, locale }: Props) { - const messages = useMessages(); + const { error } = useMessages(); return ( - + {children} ); diff --git a/src/components/storage/AddToCartButton.tsx b/src/components/storage/AddToCartButton.tsx new file mode 100644 index 0000000..c0009f7 --- /dev/null +++ b/src/components/storage/AddToCartButton.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { Button } from '@/components/ui/Button'; +import { Loader } from '@/components/ui/Loader'; +import { useLocalStorage } from '@/lib/hooks/useLocalStorage'; +import { cx } from 'cva'; + +// TODO: Type must be replaced by the type provided from database ORM. +export type StorageItem = { + id: number; + name: string; + photo_url: string; + status: string; + quantity: number; + location: string; +}; + +export type CartItem = { + id: number; + amount: number; +}; + +type AddToCartButtonProps = { + className?: string; + item: StorageItem; + t: { + addToCart: string; + removeFromCart: string; + }; +}; + +function AddToCartButton({ className, item, t }: AddToCartButtonProps) { + const [cart, setCart, isLoading] = useLocalStorage( + 'shopping-cart', + [], + ); + + if (isLoading) { + return ; + } + + function updateCart() { + if (!cart) return; + + const isInCart = cart.some((cartItem) => cartItem.id === item.id); + + if (isInCart) { + const newCart = cart.filter((cartItem) => cartItem.id !== item.id); + setCart(newCart); + } else { + const newCart = [...cart, { id: item.id, amount: 1 }]; + setCart(newCart); + } + } + + return ( + + ); +} + +export { AddToCartButton }; diff --git a/src/components/storage/BorrowDialog.tsx b/src/components/storage/BorrowDialog.tsx new file mode 100644 index 0000000..bfffe11 --- /dev/null +++ b/src/components/storage/BorrowDialog.tsx @@ -0,0 +1,56 @@ +'use client'; + +import type { CartItem } from '@/components/storage/AddToCartButton'; +import { LoanForm } from '@/components/storage/LoanForm'; +import { Button } from '@/components/ui/Button'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/Dialog'; +import { useLocalStorage } from '@/lib/hooks/useLocalStorage'; +import { cx } from '@/lib/utils'; + +type BorrowDialogProps = { + t: { + borrowNow: string; + name: string; + email: string; + phoneNumber: string; + phoneNumberDescription: string; + returnBy: string; + returnByDescription: string; + submit: string; + }; + className?: string; +}; + +function BorrowDialog({ t, className }: BorrowDialogProps) { + const [cart, _, isLoading] = useLocalStorage('shopping-cart'); + + return ( + <> + + + + + + + {t.borrowNow} + + + + + + ); +} + +export { BorrowDialog }; diff --git a/src/components/storage/ItemCard.tsx b/src/components/storage/ItemCard.tsx new file mode 100644 index 0000000..ebd734c --- /dev/null +++ b/src/components/storage/ItemCard.tsx @@ -0,0 +1,57 @@ +import type { StorageItem } from '@/components/storage/AddToCartButton'; +import { AddToCartButton } from '@/components/storage/AddToCartButton'; +import { + Card, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/Card'; +import { useTranslations } from 'next-intl'; +import Image from 'next/image'; + +function ItemCard({ + item, +}: { + item: StorageItem; +}) { + const t = useTranslations('storage'); + const tUi = useTranslations('ui'); + return ( + + +
+ {tUi('photoOf', +
+ + {item.name} + + {item.location} +
+ +

+ {t('card.quantityInfo', { quantity: item.quantity })} +

+ +
+
+ ); +} + +export { ItemCard }; diff --git a/src/components/storage/ItemCardSkeleton.tsx b/src/components/storage/ItemCardSkeleton.tsx new file mode 100644 index 0000000..242ffa8 --- /dev/null +++ b/src/components/storage/ItemCardSkeleton.tsx @@ -0,0 +1,30 @@ +import { + Card, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/Card'; +import { Skeleton } from '@/components/ui/Skeleton'; + +export function ItemCardSkeleton() { + return ( + + +
+ +
+ + + + + + +
+ + + + +
+ ); +} diff --git a/src/components/storage/LoanForm.tsx b/src/components/storage/LoanForm.tsx new file mode 100644 index 0000000..81b26ac --- /dev/null +++ b/src/components/storage/LoanForm.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { Button } from '@/components/ui/Button'; +import { Calendar } from '@/components/ui/Calendar'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} 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({ + phone: z.string().min(1), + returnBy: z.date().min(new Date()), +}); + +type LoanFormProps = { + t: { + borrowNow: string; + name: string; + email: string; + phoneNumber: string; + phoneNumberDescription: string; + returnBy: string; + returnByDescription: string; + submit: string; + }; +}; + +function LoanForm({ t }: LoanFormProps) { + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + phone: '', + returnBy: new Date(), + }, + }); + + function onSubmit(values: z.infer) { + // TODO: Add new loan to database + console.log(values); + } + + return ( + <> +
+ + ( + + {t.phoneNumber} + + + + {t.phoneNumberDescription} + + + )} + /> + ( + + {t.returnBy} + + + + {t.returnByDescription} + + + )} + /> + + + + + ); +} + +export { LoanForm }; diff --git a/src/components/storage/SelectorsSkeleton.tsx b/src/components/storage/SelectorsSkeleton.tsx new file mode 100644 index 0000000..8bf78b1 --- /dev/null +++ b/src/components/storage/SelectorsSkeleton.tsx @@ -0,0 +1,13 @@ +import { Skeleton } from '@/components/ui/Skeleton'; +import { useId } from 'react'; + +function SelectorsSkeleton() { + return ( +
+ + +
+ ); +} + +export { SelectorsSkeleton }; diff --git a/src/components/storage/ShoppingCartClearDialog.tsx b/src/components/storage/ShoppingCartClearDialog.tsx new file mode 100644 index 0000000..cd6721b --- /dev/null +++ b/src/components/storage/ShoppingCartClearDialog.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { ConfirmDialog } from '@/components/composites/ConfirmDialog'; +import type { CartItem } from '@/components/storage/AddToCartButton'; +import { useLocalStorage } from '@/lib/hooks/useLocalStorage'; +import { cx } from '@/lib/utils'; +import { XIcon } from 'lucide-react'; + +type ShoppingCartClearDialogProps = { + className?: string; + t: { + clearCart: string; + clearCartDescription: string; + clear: string; + cancel: string; + }; +}; + +function ShoppingCartClearDialog({ + className, + t, +}: ShoppingCartClearDialogProps) { + const [cart, setCart, isLoading] = + useLocalStorage('shopping-cart'); + + return ( + setCart(null)} + t={{ + title: t.clearCart, + description: t.clearCartDescription, + confirm: t.clear, + cancel: t.cancel, + }} + > + + ); +} + +export { ShoppingCartClearDialog }; diff --git a/src/components/storage/ShoppingCartLink.tsx b/src/components/storage/ShoppingCartLink.tsx new file mode 100644 index 0000000..6bd6efd --- /dev/null +++ b/src/components/storage/ShoppingCartLink.tsx @@ -0,0 +1,55 @@ +'use client'; +import type { CartItem } from '@/components/storage/AddToCartButton'; +import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/Tooltip'; +import { useLocalStorage } from '@/lib/hooks/useLocalStorage'; +import { Link } from '@/lib/locale/navigation'; +import { ShoppingCartIcon } from 'lucide-react'; + +type ShoppingCartLinkProps = { + t: { + viewShoppingCart: string; + }; +}; + +function ShoppingCartLink({ t }: ShoppingCartLinkProps) { + const [cart, _, isLoading] = useLocalStorage('shopping-cart'); + + return ( + + + +
+ + {!isLoading && cart && cart.length > 0 && ( + + {cart.length} + + )} +
+
+ +

{t.viewShoppingCart}

+
+
+
+ ); +} + +export { ShoppingCartLink }; diff --git a/src/components/storage/ShoppingCartTable.tsx b/src/components/storage/ShoppingCartTable.tsx new file mode 100644 index 0000000..703105c --- /dev/null +++ b/src/components/storage/ShoppingCartTable.tsx @@ -0,0 +1,117 @@ +'use client'; + +import type { CartItem } from '@/components/storage/AddToCartButton'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/Table'; +import { useLocalStorage } from '@/lib/hooks/useLocalStorage'; +import { XIcon } from 'lucide-react'; + +// TODO: Must be replaced by requesting the data from a database. +import { items } from '@/mock-data/items'; +import { ShoppingCartTableSkeleton } from './ShoppingCartTableSkeleton'; + +type ShoppingCartTableProps = { + t: { + tableDescription: string; + productId: string; + productName: string; + location: string; + unitsAvailable: string; + cartEmpty: string; + amountOfItemARIA: string; + }; +}; + +function ShoppingCartTable({ t }: ShoppingCartTableProps) { + const [cart, setCart, isLoading] = useLocalStorage( + 'shopping-cart', + [], + ); + + if (isLoading) { + return ; + } + + if (!cart || cart.length === 0) { + return

{t.cartEmpty}

; + } + + const itemsInCart = items.filter((item) => + cart.some((cartItem) => cartItem.id === item.id), + ); + + function updateAmountInCart(id: number, newValue: number) { + if (!cart) return; + + const newCart = cart.map((cartItem) => + cartItem.id === id ? { ...cartItem, amount: newValue } : cartItem, + ); + setCart(newCart); + } + + function removeItem(id: number) { + if (!cart) return; + + const newCart = cart.filter((cartItem: CartItem) => cartItem.id !== id); + setCart(newCart); + } + + return ( + + + + + {t.productId} + {t.productName} + {t.location} + {t.unitsAvailable} + + + + + {itemsInCart.map((item) => ( + + + cartItem.id === item.id)?.amount || 0 + } + onChange={(e) => + updateAmountInCart(item.id, Number(e.currentTarget.value)) + } + className='w-[80px]' + aria-label={t.amountOfItemARIA} + /> + + {item.id} + {item.name} + {item.location} + {item.quantity} + + + + + ))} + +
+ ); +} + +export { ShoppingCartTable }; diff --git a/src/components/storage/ShoppingCartTableSkeleton.tsx b/src/components/storage/ShoppingCartTableSkeleton.tsx new file mode 100644 index 0000000..62a3aef --- /dev/null +++ b/src/components/storage/ShoppingCartTableSkeleton.tsx @@ -0,0 +1,69 @@ +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Skeleton } from '@/components/ui/Skeleton'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/Table'; +import { XIcon } from 'lucide-react'; +import { useId } from 'react'; + +type ShoppingCartTableSkeletonProps = { + t: { + productId: string; + productName: string; + location: string; + unitsAvailable: string; + }; +}; + +function ShoppingCartTableSkeleton({ t }: ShoppingCartTableSkeletonProps) { + return ( + + + + + + + {t.productId} + {t.productName} + {t.location} + {t.unitsAvailable} + + + + + {Array.from({ length: 3 }).map(() => ( + + + + + + + + + + + + + + + + + + + + + ))} + +
+ ); +} + +export { ShoppingCartTableSkeleton }; diff --git a/src/components/storage/SkeletonCard.tsx b/src/components/storage/SkeletonCard.tsx deleted file mode 100644 index 8c3cafb..0000000 --- a/src/components/storage/SkeletonCard.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Skeleton } from '@/components/ui/Skeleton'; - -export function SkeletonCard() { - return ( -
-
- - - -
-
- - -
-
- ); -} diff --git a/src/components/ui/Calendar.tsx b/src/components/ui/Calendar.tsx new file mode 100644 index 0000000..905dbbc --- /dev/null +++ b/src/components/ui/Calendar.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; +import type * as React from 'react'; +import { DayPicker } from 'react-day-picker'; + +import { buttonVariants } from '@/components/ui/Button'; +import { cx } from '@/lib/utils'; + +export type CalendarProps = React.ComponentProps; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + locale, + ...props +}: CalendarProps) { + return ( + , + IconRight: ({ ...props }) => , + }} + weekStartsOn={1} + {...props} + /> + ); +} +Calendar.displayName = 'Calendar'; + +export { Calendar }; diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx index 0479465..ac5b4b8 100644 --- a/src/components/ui/Card.tsx +++ b/src/components/ui/Card.tsx @@ -1,6 +1,10 @@ import { cx } from '@/lib/utils'; import * as React from 'react'; +type CardTitleProps = { + level?: 'h2' | 'h3' | 'h4'; +} & React.HTMLAttributes; + const Card = React.forwardRef< HTMLDivElement, React.HTMLAttributes @@ -28,19 +32,22 @@ const CardHeader = React.forwardRef< )); CardHeader.displayName = 'CardHeader'; -const CardTitle = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -

-)); +const CardTitle = React.forwardRef( + ({ level = 'h3', className, ...props }, ref) => { + const Component = level; + + return ( + + ); + }, +); CardTitle.displayName = 'CardTitle'; const CardDescription = React.forwardRef< diff --git a/src/components/ui/Combobox.tsx b/src/components/ui/Combobox.tsx index 57a4b93..d51c3b2 100644 --- a/src/components/ui/Combobox.tsx +++ b/src/components/ui/Combobox.tsx @@ -27,6 +27,9 @@ type ComboboxProps = { defaultPlaceholder: string; buttonClassName?: string; contentClassName?: string; + valueCallback?: (value: string | null) => void; + initialValue?: string | null; + ariaLabel?: string; }; function Combobox({ @@ -35,9 +38,12 @@ function Combobox({ defaultPlaceholder, buttonClassName, contentClassName, + valueCallback, + initialValue, + ariaLabel, }: ComboboxProps) { const [open, setOpen] = React.useState(false); - const [value, setValue] = React.useState(''); + const [value, setValue] = React.useState(initialValue ?? ''); return ( @@ -46,6 +52,7 @@ function Combobox({ variant='outline' role='combobox' aria-expanded={open} + aria-label={ariaLabel} className={cx('w-[200px] justify-between', buttonClassName)} > {value @@ -65,8 +72,14 @@ function Combobox({ key={choice.value} value={choice.value} onSelect={(currentValue) => { - setValue(currentValue === value ? '' : currentValue); + // Set newValue to null if user selects the same value twice + const newValue = + currentValue === value ? null : currentValue; + setValue(newValue); setOpen(false); + if (valueCallback) { + valueCallback(newValue); + } }} > 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/Form.tsx b/src/components/ui/Form.tsx new file mode 100644 index 0000000..c945d9f --- /dev/null +++ b/src/components/ui/Form.tsx @@ -0,0 +1,179 @@ +'use client'; + +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'; + +import { Label } from '@/components/ui/Label'; +import { cx } from '@/lib/utils'; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + 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 '); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + 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(); + + return ( +