-
-
+
+
+
+
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 (
+ <>
+
+ >
+ );
+}
+
+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 (
+
+
+
+
+
+
+ {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 (
+ <>
+
+
+ >
+ );
+}
+
+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,
+ }}
+ >
+
+ {t.clearCart}
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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 (
+
+ );
+});
+FormLabel.displayName = 'FormLabel';
+
+const FormControl = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ ...props }, ref) => {
+ const { error, formItemId, formDescriptionId, formMessageId } =
+ useFormField();
+
+ return (
+
+ );
+});
+FormControl.displayName = 'FormControl';
+
+const FormDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { formDescriptionId } = useFormField();
+
+ return (
+
+ );
+});
+FormDescription.displayName = 'FormDescription';
+
+const FormMessage = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, children, ...props }, ref) => {
+ const { error, formMessageId } = useFormField();
+ const body = error ? String(error?.message) : children;
+
+ if (!body) {
+ return null;
+ }
+
+ return (
+
+ {body}
+
+ );
+});
+FormMessage.displayName = 'FormMessage';
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+};
diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx
index a16957e..18d6b46 100644
--- a/src/components/ui/Input.tsx
+++ b/src/components/ui/Input.tsx
@@ -10,7 +10,7 @@ const Input = React.forwardRef(
,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+));
+Label.displayName = LabelPrimitive.Root.displayName;
+
+export { Label };
diff --git a/src/components/ui/Loader.tsx b/src/components/ui/Loader.tsx
new file mode 100644
index 0000000..11998a9
--- /dev/null
+++ b/src/components/ui/Loader.tsx
@@ -0,0 +1,30 @@
+import { type VariantProps, cva } from '@/lib/utils';
+import { Loader2Icon } from 'lucide-react';
+
+const loaderVariants = cva({
+ base: 'animate-spin text-muted-foreground',
+ variants: {
+ size: {
+ default: 'mx-4 my-2 size-6',
+ sm: 'mx-3 my-1.5 size-4',
+ lg: 'mx-6 my-6 size-8',
+ xl: 'mx-8 my-8 size-10',
+ none: '',
+ },
+ },
+ defaultVariants: {
+ size: 'default',
+ },
+});
+
+function Loader({
+ className,
+ size,
+ ...props
+}: React.SVGProps & VariantProps) {
+ return (
+
+ );
+}
+
+export { Loader };
diff --git a/src/components/ui/SearchBar.tsx b/src/components/ui/SearchBar.tsx
deleted file mode 100644
index de8c7c0..0000000
--- a/src/components/ui/SearchBar.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { cx } from '@/lib/utils';
-import { SearchIcon } from 'lucide-react';
-import * as React from 'react';
-
-type SearchBarProps = React.InputHTMLAttributes;
-
-/**
- * This component creates a full search bar with an icon.
- * The ref, if used, is passed onto the input element, not the wrapper for the component.
- */
-const SearchBar = React.forwardRef(
- ({ className, type, ...props }, ref) => {
- return (
-
-
-
-
- );
- },
-);
-SearchBar.displayName = 'SearchBar';
-
-export { SearchBar };
diff --git a/src/components/ui/Table.tsx b/src/components/ui/Table.tsx
new file mode 100644
index 0000000..4d00f59
--- /dev/null
+++ b/src/components/ui/Table.tsx
@@ -0,0 +1,117 @@
+import * as React from 'react';
+
+import { cx } from '@/lib/utils';
+
+const Table = React.forwardRef<
+ HTMLTableElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+Table.displayName = 'Table';
+
+const TableHeader = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+TableHeader.displayName = 'TableHeader';
+
+const TableBody = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+TableBody.displayName = 'TableBody';
+
+const TableFooter = React.forwardRef<
+ HTMLTableSectionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+ tr]:last:border-b-0',
+ className,
+ )}
+ {...props}
+ />
+));
+TableFooter.displayName = 'TableFooter';
+
+const TableRow = React.forwardRef<
+ HTMLTableRowElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+TableRow.displayName = 'TableRow';
+
+const TableHead = React.forwardRef<
+ HTMLTableCellElement,
+ React.ThHTMLAttributes
+>(({ className, ...props }, ref) => (
+ |
+));
+TableHead.displayName = 'TableHead';
+
+const TableCell = React.forwardRef<
+ HTMLTableCellElement,
+ React.TdHTMLAttributes
+>(({ className, ...props }, ref) => (
+ |
+));
+TableCell.displayName = 'TableCell';
+
+const TableCaption = React.forwardRef<
+ HTMLTableCaptionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+TableCaption.displayName = 'TableCaption';
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+};
diff --git a/src/lib/hooks/useLocalStorage.ts b/src/lib/hooks/useLocalStorage.ts
new file mode 100644
index 0000000..ebc6a74
--- /dev/null
+++ b/src/lib/hooks/useLocalStorage.ts
@@ -0,0 +1,103 @@
+import { useCallback, useEffect, useState } from 'react';
+
+declare global {
+ interface WindowEventMap {
+ 'local-storage': CustomEvent;
+ }
+}
+
+function useLocalStorage(
+ key: string,
+ initialValue?: T | (() => T),
+): [T | undefined, (value: T | null | undefined) => void, boolean] {
+ const [isLoading, setIsLoading] = useState(true);
+ const [storedValue, setStoredValue] = useState(() => {
+ let initValue: T | undefined;
+ if (typeof initialValue === 'function') {
+ initValue = (initialValue as () => T)();
+ } else {
+ initValue = initialValue;
+ }
+
+ if (typeof window === 'undefined') {
+ return initValue;
+ }
+
+ const raw = localStorage.getItem(key);
+ if (raw) {
+ try {
+ return JSON.parse(raw) as T;
+ } catch {
+ return initValue;
+ }
+ } else {
+ return initValue;
+ }
+ });
+
+ const setValue = useCallback(
+ (value: T | null | undefined) => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ const newValue = value instanceof Function ? value(storedValue) : value;
+ if (!newValue || (Array.isArray(newValue) && newValue.length === 0)) {
+ localStorage.removeItem(key);
+ let defaultValue: T | undefined;
+ if (typeof initialValue === 'function') {
+ defaultValue = (initialValue as () => T)();
+ } else {
+ defaultValue = initialValue;
+ }
+ setStoredValue(defaultValue);
+ } else {
+ localStorage.setItem(key, JSON.stringify(newValue));
+ setStoredValue(newValue);
+ }
+ window.dispatchEvent(new StorageEvent('local-storage', { key }));
+ },
+ [key, storedValue, initialValue],
+ );
+
+ useEffect(() => {
+ const handleStorageChange = (event: StorageEvent | CustomEvent) => {
+ if ((event as StorageEvent).key && (event as StorageEvent).key !== key) {
+ return;
+ }
+ const raw = localStorage.getItem(key);
+ if (raw) {
+ try {
+ setStoredValue(JSON.parse(raw) as T);
+ } catch {
+ let defaultValue: T | undefined;
+ if (typeof initialValue === 'function') {
+ defaultValue = (initialValue as () => T)();
+ } else {
+ defaultValue = initialValue;
+ }
+ setStoredValue(defaultValue);
+ }
+ } else {
+ let defaultValue: T | undefined;
+ if (typeof initialValue === 'function') {
+ defaultValue = (initialValue as () => T)();
+ } else {
+ defaultValue = initialValue;
+ }
+ setStoredValue(defaultValue);
+ }
+ };
+
+ window.addEventListener('storage', handleStorageChange);
+ window.addEventListener('local-storage', handleStorageChange);
+ setIsLoading(false);
+ return () => {
+ window.removeEventListener('storage', handleStorageChange);
+ window.removeEventListener('local-storage', handleStorageChange);
+ };
+ }, [key, initialValue]);
+
+ return [storedValue, setValue, isLoading];
+}
+
+export { useLocalStorage };
diff --git a/src/lib/locale/index.ts b/src/lib/locale/index.ts
index 20bda14..8ffd34e 100644
--- a/src/lib/locale/index.ts
+++ b/src/lib/locale/index.ts
@@ -29,5 +29,13 @@ export const routing = defineRouting({
en: '/about',
no: '/om-oss',
},
+ '/storage': {
+ en: '/storage',
+ no: '/lager',
+ },
+ '/storage/shopping-cart': {
+ en: '/storage/shopping-cart',
+ no: '/lager/handlekurv',
+ },
},
});
diff --git a/src/lib/locale/i18n.ts b/src/lib/locale/request.ts
similarity index 81%
rename from src/lib/locale/i18n.ts
rename to src/lib/locale/request.ts
index 5754d72..5a5fb6e 100644
--- a/src/lib/locale/i18n.ts
+++ b/src/lib/locale/request.ts
@@ -3,8 +3,7 @@ import { getRequestConfig } from 'next-intl/server';
import { notFound } from 'next/navigation';
export default getRequestConfig(async ({ locale }) => {
- // @ts-ignore
- if (!routing.locales.includes(locale)) notFound();
+ if (!routing.locales.includes(locale as 'en')) notFound();
return {
messages: (await import(`../../../messages/${locale}.json`))
.default as Messages,
diff --git a/src/mock-data/items.ts b/src/mock-data/items.ts
index ce106f9..3e26d06 100644
--- a/src/mock-data/items.ts
+++ b/src/mock-data/items.ts
@@ -1,5 +1,6 @@
const items = [
{
+ id: 34872,
name: 'Laptop',
photo_url: 'https://example.com/photos/laptop.jpg',
status: 'Operational',
@@ -7,6 +8,7 @@ const items = [
location: 'Storage Room A',
},
{
+ id: 58392,
name: 'Desktop PC',
photo_url: 'https://example.com/photos/desktop_pc.jpg',
status: 'Operational',
@@ -14,6 +16,7 @@ const items = [
location: 'Workstation Area 1',
},
{
+ id: 72541,
name: 'Monitor',
photo_url: 'https://example.com/photos/monitor.jpg',
status: 'Operational',
@@ -21,6 +24,7 @@ const items = [
location: 'Storage Room B',
},
{
+ id: 91834,
name: 'Keyboard',
photo_url: 'https://example.com/photos/keyboard.jpg',
status: 'Operational',
@@ -28,6 +32,7 @@ const items = [
location: 'Storage Room A',
},
{
+ id: 12095,
name: 'Mouse',
photo_url: 'https://example.com/photos/mouse.jpg',
status: 'Operational',
@@ -35,6 +40,7 @@ const items = [
location: 'Storage Room A',
},
{
+ id: 65738,
name: 'Router',
photo_url: 'https://example.com/photos/router.jpg',
status: 'Operational',
@@ -42,6 +48,7 @@ const items = [
location: 'Networking Room',
},
{
+ id: 23984,
name: 'Ethernet Cable',
photo_url: 'https://example.com/photos/ethernet_cable.jpg',
status: 'Operational',
@@ -49,6 +56,7 @@ const items = [
location: 'Networking Room',
},
{
+ id: 48152,
name: 'External Hard Drive',
photo_url: 'https://example.com/photos/external_hard_drive.jpg',
status: 'Operational',
@@ -56,6 +64,7 @@ const items = [
location: 'Storage Room B',
},
{
+ id: 36829,
name: 'USB Flash Drive',
photo_url: 'https://example.com/photos/usb_flash_drive.jpg',
status: 'Operational',
@@ -63,6 +72,7 @@ const items = [
location: 'Storage Room B',
},
{
+ id: 50273,
name: 'Power Supply Unit (PSU)',
photo_url: 'https://example.com/photos/psu.jpg',
status: 'Operational',
@@ -70,6 +80,7 @@ const items = [
location: 'Storage Room C',
},
{
+ id: 17492,
name: 'Graphics Card',
photo_url: 'https://example.com/photos/graphics_card.jpg',
status: 'Operational',
@@ -77,6 +88,7 @@ const items = [
location: 'Storage Room C',
},
{
+ id: 78356,
name: 'RAM Module',
photo_url: 'https://example.com/photos/ram_module.jpg',
status: 'Operational',
@@ -84,6 +96,7 @@ const items = [
location: 'Storage Room C',
},
{
+ id: 92031,
name: 'Motherboard',
photo_url: 'https://example.com/photos/motherboard.jpg',
status: 'Operational',
@@ -91,6 +104,7 @@ const items = [
location: 'Storage Room C',
},
{
+ id: 38627,
name: 'CPU',
photo_url: 'https://example.com/photos/cpu.jpg',
status: 'Operational',
@@ -98,6 +112,7 @@ const items = [
location: 'Storage Room C',
},
{
+ id: 49082,
name: 'SSD',
photo_url: 'https://example.com/photos/ssd.jpg',
status: 'Operational',
@@ -105,6 +120,7 @@ const items = [
location: 'Storage Room C',
},
{
+ id: 85731,
name: 'Network Switch',
photo_url: 'https://example.com/photos/network_switch.jpg',
status: 'Operational',
@@ -112,6 +128,7 @@ const items = [
location: 'Networking Room',
},
{
+ id: 37429,
name: 'Soldering Iron',
photo_url: 'https://example.com/photos/soldering_iron.jpg',
status: 'Operational',
@@ -119,6 +136,7 @@ const items = [
location: 'Repair Station',
},
{
+ id: 90321,
name: 'Multimeter',
photo_url: 'https://example.com/photos/multimeter.jpg',
status: 'Operational',
@@ -126,6 +144,7 @@ const items = [
location: 'Repair Station',
},
{
+ id: 65704,
name: 'Screwdriver Set',
photo_url: 'https://example.com/photos/screwdriver_set.jpg',
status: 'Operational',
@@ -133,6 +152,7 @@ const items = [
location: 'Toolbox 1',
},
{
+ id: 48139,
name: 'Anti-static Wrist Strap',
photo_url: 'https://example.com/photos/anti_static_wrist_strap.jpg',
status: 'Operational',
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 73a4ee5..6480b71 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -2,6 +2,7 @@ import tailwindFluid, { extract, screens, fontSize } from 'fluid-tailwind';
import tailwindScrollbar from 'tailwind-scrollbar';
import type { Config } from 'tailwindcss';
import tailwindAnimate from 'tailwindcss-animate';
+import tailwindRadix from 'tailwindcss-radix';
import { fontFamily } from 'tailwindcss/defaultTheme';
const config = {
@@ -78,6 +79,9 @@ const config = {
},
},
plugins: [
+ tailwindRadix({
+ variantPrefix: false,
+ }),
tailwindFluid,
tailwindAnimate,
tailwindScrollbar({ nocompatible: true }),
diff --git a/tsconfig.json b/tsconfig.json
index 6cb01c3..9c1115c 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -31,10 +31,10 @@
},
"include": [
".eslintrc.js",
+ "postcss.config.js",
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
- "**/*.cjs",
"**/*.js",
".next/types/**/*.ts"
],