chore: upgrade dependencies, restructure shared libs, and fix UI

- Upgrade Nx 22.1.1 → 22.6.3 and all patch/minor dependencies
- Restructure shared libs: move business logic from utils to service
- Consolidate shadcn-ui into ui lib with atomic design pattern
- Fix container centering for landing app (Tailwind v4 compatibility)
- Fix button styling by updating @source directive in globals.css
- Fix SiCss3 → SiCss rename in react-icons 5.6
- Fix duplicate useSession export conflict
- Remove dead code, comments, and unused files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
maulanasdqn
2026-03-31 01:44:17 +07:00
co-authored by Claude Opus 4.6
parent f68d97188c
commit 3f4461c65c
231 changed files with 6062 additions and 39166 deletions
+38 -44
View File
@@ -1,3 +1,4 @@
import { cva, type VariantProps } from 'class-variance-authority';
import {
FC,
ReactElement,
@@ -6,62 +7,55 @@ import {
} from 'react';
import { cn } from '@imphnen-frontend-service/utils';
type TButtonVariant =
| 'primary'
| 'secondary'
| 'success'
| 'danger'
| 'text'
| 'bordered';
type TButtonSize = 'sm' | 'md' | 'lg';
export const buttonVariants = cva(
'inline-flex items-center justify-center font-[600] rounded-md px-[16px] py-[10px] transition-colors duration-200 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed',
{
variants: {
variant: {
primary: 'bg-primary-500 hover:bg-primary-600 text-white shadow-md',
secondary:
'bg-white dark:bg-gray-800 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-gray-50 dark:hover:bg-gray-700 text-primary-500 dark:text-primary-400 shadow-md dark:shadow-gray-900/50 border dark:border-gray-700',
text: 'bg-transparent hover:text-primary-600 dark:hover:text-primary-400 hover:bg-gray-50 dark:hover:bg-gray-800 text-primary-500 dark:text-primary-400',
bordered:
'border border-primary-500 dark:border-primary-400 hover:border-primary-600 dark:hover:border-primary-300 bg-transparent hover:text-primary-600 dark:hover:text-primary-300 hover:bg-gray-50 dark:hover:bg-gray-800 text-primary-500 dark:text-primary-400',
success: 'bg-success-500 hover:bg-success-600 text-white shadow-md',
danger:
'bg-danger-100 dark:bg-danger-500/20 hover:bg-danger-200 dark:hover:bg-danger-500/30 text-danger-500 shadow-md dark:shadow-gray-900/50',
},
size: {
sm: 'text-[12px] max-h-[36px]',
md: 'text-[15px] max-h-[40px]',
lg: 'text-[19px] max-h-[44px]',
icon: 'h-9 w-9 p-0',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
);
type TButtonProps = DetailedHTMLProps<
ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
> & {
variant?: TButtonVariant;
size?: TButtonSize;
};
const variantClasses: Record<TButtonVariant, string> = {
primary: 'bg-primary-500 hover:bg-primary-600 text-white shadow-md',
secondary:
'bg-white dark:bg-gray-800 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-gray-50 dark:hover:bg-gray-700 text-primary-500 dark:text-primary-400 shadow-md dark:shadow-gray-900/50 border dark:border-gray-700',
text: 'bg-transparent hover:text-primary-600 dark:hover:text-primary-400 hover:bg-gray-50 dark:hover:bg-gray-800 text-primary-500 dark:text-primary-400',
bordered:
'border border-primary-500 dark:border-primary-400 hover:border-primary-600 dark:hover:border-primary-300 bg-transparent hover:text-primary-600 dark:hover:text-primary-300 hover:bg-gray-50 dark:hover:bg-gray-800 text-primary-500 dark:text-primary-400',
success: 'bg-success-500 hover:bg-success-600 text-white shadow-md',
danger:
'bg-danger-100 dark:bg-danger-500/20 hover:bg-danger-200 dark:hover:bg-danger-500/30 text-danger-500 shadow-md dark:shadow-gray-900/50',
};
const sizeClasses: Record<TButtonSize, string> = {
sm: 'text-[12px] max-h-[36px]',
md: 'text-[15px] max-h-[40px]',
lg: 'text-[19px] max-h-[44px]',
};
const disabledClass = 'opacity-50 cursor-not-allowed';
> &
VariantProps<typeof buttonVariants>;
export const Button: FC<TButtonProps> = ({
variant = 'primary',
size = 'md',
variant,
size,
disabled,
className,
children,
...rest
}): ReactElement => {
const mergedClassName = cn(
'inline-flex items-center justify-center font-[600] rounded-md px-[16px] py-[10px]',
'transition-colors duration-200 cursor-pointer',
sizeClasses[size],
variantClasses[variant],
disabled && disabledClass,
className
);
return (
<button className={mergedClassName} disabled={disabled} {...rest}>
<button
className={cn(buttonVariants({ variant, size }), className)}
disabled={disabled}
{...rest}
>
{children}
</button>
);
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './card';
describe('Card', () => {
it('renders card with content', () => {
render(<Card>Card content</Card>);
expect(screen.getByText('Card content')).toBeInTheDocument();
});
it('applies custom className', () => {
render(<Card className="custom-class">Content</Card>);
expect(screen.getByText('Content')).toHaveClass('custom-class');
});
it('renders full card composition', () => {
render(
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
<CardDescription>Description</CardDescription>
</CardHeader>
<CardContent>Body</CardContent>
<CardFooter>Footer</CardFooter>
</Card>
);
expect(screen.getByText('Title')).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText('Body')).toBeInTheDocument();
expect(screen.getByText('Footer')).toBeInTheDocument();
});
});
+35
View File
@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './card';
const meta: Meta<typeof Card> = {
title: 'Components/Card',
component: Card,
};
export default meta;
type Story = StoryObj<typeof Card>;
export const Default: Story = {
render: () => (
<Card className="w-[350px]">
<CardHeader>
<CardTitle>Card Title</CardTitle>
<CardDescription>Card description goes here.</CardDescription>
</CardHeader>
<CardContent>
<p>Card content body.</p>
</CardContent>
<CardFooter>
<p>Card footer</p>
</CardFooter>
</Card>
),
};
export const Simple: Story = {
render: () => (
<Card className="w-[350px] p-6">
<p>Simple card with padding.</p>
</Card>
),
};
+82
View File
@@ -0,0 +1,82 @@
import * as React from 'react';
import { cn } from '@imphnen-frontend-service/utils';
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-xl border bg-card text-card-foreground shadow',
className
)}
{...props}
/>
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
));
CardFooter.displayName = 'CardFooter';
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};
+1
View File
@@ -0,0 +1 @@
export * from './card';
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from './dialog';
describe('Dialog', () => {
it('opens dialog when trigger is clicked', async () => {
render(
<Dialog>
<DialogTrigger>Open</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Dialog Title</DialogTitle>
<DialogDescription>Dialog description</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
);
const user = userEvent.setup();
await user.click(screen.getByText('Open'));
expect(screen.getByText('Dialog Title')).toBeInTheDocument();
expect(screen.getByText('Dialog description')).toBeInTheDocument();
});
});
@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/react';
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from './dialog';
const meta: Meta<typeof Dialog> = {
title: 'Components/Dialog',
component: Dialog,
};
export default meta;
type Story = StoryObj<typeof Dialog>;
export const Default: Story = {
render: () => (
<Dialog>
<DialogTrigger>Open Dialog</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader>
<DialogFooter>
<button>Cancel</button>
<button>Continue</button>
</DialogFooter>
</DialogContent>
</Dialog>
),
};
+142
View File
@@ -0,0 +1,142 @@
'use client';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import * as React from 'react';
import { LuX } from 'react-icons/lu';
import { cn } from '@imphnen-frontend-service/utils';
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<LuX />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+1
View File
@@ -0,0 +1 @@
export * from './dialog';
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
Drawer,
DrawerTrigger,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from './drawer';
describe('Drawer', () => {
it('opens drawer when trigger is clicked', async () => {
render(
<Drawer>
<DrawerTrigger>Open Drawer</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Drawer Title</DrawerTitle>
</DrawerHeader>
</DrawerContent>
</Drawer>
);
const user = userEvent.setup();
await user.click(screen.getByText('Open Drawer'));
expect(screen.getByText('Drawer Title')).toBeInTheDocument();
});
});
@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/react';
import {
Drawer,
DrawerTrigger,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerDescription,
DrawerFooter,
DrawerClose,
} from './drawer';
const meta: Meta<typeof Drawer> = {
title: 'Components/Drawer',
component: Drawer,
};
export default meta;
type Story = StoryObj<typeof Drawer>;
export const Default: Story = {
render: () => (
<Drawer>
<DrawerTrigger>Open Drawer</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Drawer Title</DrawerTitle>
<DrawerDescription>Drawer description goes here.</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose>Close</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
),
};
+131
View File
@@ -0,0 +1,131 @@
'use client';
import * as React from 'react';
import { Drawer as DrawerPrimitive } from 'vaul';
import { cn } from '@imphnen-frontend-service/utils';
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col',
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm',
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm',
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-header"
className={cn('flex flex-col gap-1.5 p-4', className)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn('text-foreground font-semibold', className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerOverlay,
DrawerPortal,
DrawerTitle,
DrawerTrigger,
};
+1
View File
@@ -0,0 +1 @@
export * from './drawer';
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { useForm } from 'react-hook-form';
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from './form';
function TestForm() {
const form = useForm({ defaultValues: { name: '' } });
return (
<Form {...form}>
<form>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
}
describe('Form', () => {
it('renders form with label and input', () => {
render(<TestForm />);
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
});
+47
View File
@@ -0,0 +1,47 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useForm } from 'react-hook-form';
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
} from './form';
const meta: Meta = {
title: 'Components/Form',
};
export default meta;
type Story = StoryObj;
function ExampleForm() {
const form = useForm({ defaultValues: { username: '' } });
return (
<Form {...form}>
<form className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<input placeholder="Enter username" {...field} />
</FormControl>
<FormDescription>This is your public display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
}
export const Default: Story = {
render: () => <ExampleForm />,
};
+167
View File
@@ -0,0 +1,167 @@
'use client';
import * as LabelPrimitive from '@radix-ui/react-label';
import { Slot } from '@radix-ui/react-slot';
import * as React from 'react';
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from 'react-hook-form';
import { cn } from '@imphnen-frontend-service/utils';
import { Label } from '../label';
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>');
}
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<FormItemContextValue>(
{} as FormItemContextValue
);
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn('grid gap-2', className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn('data-[error=true]:text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? '') : props.children;
if (!body) {
return null;
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn('text-destructive text-sm', className)}
{...props}
>
{body}
</p>
);
}
export {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useFormField,
};
+1
View File
@@ -0,0 +1 @@
export * from './form';
+6 -1
View File
@@ -1,5 +1,10 @@
export * from './button';
export * from './card';
export * from './dialog';
export * from './drawer';
export * from './form';
export * from './input';
export * from './label';
export * from './select';
export * from './textarea';
export * from './select'
export * from './toggle';
+2
View File
@@ -1,3 +1,5 @@
'use client';
import {
DetailedHTMLProps,
FC,
+1
View File
@@ -0,0 +1 @@
export * from './label';
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Label } from './label';
describe('Label', () => {
it('renders label text', () => {
render(<Label>Username</Label>);
expect(screen.getByText('Username')).toBeInTheDocument();
});
it('applies custom className', () => {
render(<Label className="custom-class">Email</Label>);
expect(screen.getByText('Email')).toHaveClass('custom-class');
});
it('associates with input via htmlFor', () => {
render(<Label htmlFor="email-input">Email</Label>);
expect(screen.getByText('Email')).toHaveAttribute('for', 'email-input');
});
});
+25
View File
@@ -0,0 +1,25 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Label } from './label';
const meta: Meta<typeof Label> = {
title: 'Components/Label',
component: Label,
};
export default meta;
type Story = StoryObj<typeof Label>;
export const Default: Story = {
args: {
children: 'Email address',
},
};
export const WithInput: Story = {
render: () => (
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<input id="email" type="email" placeholder="Enter your email" />
</div>
),
};
+23
View File
@@ -0,0 +1,23 @@
'use client';
import * as LabelPrimitive from '@radix-ui/react-label';
import * as React from 'react';
import { cn } from '@imphnen-frontend-service/utils';
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className
)}
{...props}
/>
);
}
export { Label };
@@ -16,13 +16,11 @@ export const RegisterMentorStep: FC<TRegisterMentorStep> = ({ step }): ReactElem
return (
<div className="relative w-full px-8">
{/* Garis dasar (abu/biru muda) */}
<div className="absolute top-5 left-8 right-8 h-0.5 bg-blue-100 z-0" />
{/* Garis progress biru (dinamis) */}
<div
className="absolute top-5 left-8 h-0.5 bg-blue-500 z-0 transition-all duration-300"
style={{ width: `calc(${progressPercent}% - 0.5rem)` }} // -0.5rem agar tidak overlap ke lingkaran
style={{ width: `calc(${progressPercent}% - 0.5rem)` }}
/>
<div className="flex justify-between relative z-10">
@@ -32,7 +30,6 @@ export const RegisterMentorStep: FC<TRegisterMentorStep> = ({ step }): ReactElem
return (
<div key={index} className="flex flex-col items-center flex-1 text-center">
{/* Lingkaran angka */}
<div
className={`w-10 h-10 rounded-full flex items-center justify-center border-2 ${
isActive
@@ -42,7 +39,6 @@ export const RegisterMentorStep: FC<TRegisterMentorStep> = ({ step }): ReactElem
>
<span className="font-bold">{currentStep}</span>
</div>
{/* Label */}
<span className="text-xs text-blue-600 mt-2 leading-tight">{label}</span>
</div>
);
@@ -19,7 +19,8 @@ import {
import { Button } from '../../atoms';
import { FC, ReactElement, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { cn, For, useSession } from '@imphnen-frontend-service/utils';
import { cn, For } from '@imphnen-frontend-service/utils';
import { useSession } from '@imphnen-frontend-service/service';
type MenuItem = {
label: string;
@@ -274,20 +275,16 @@ export const BackofficeSidebar: FC<SidebarProps> = ({
return (
<>
{/* Desktop Sidebar - visible on lg+, sticky */}
<div className="hidden lg:block sticky top-0 h-screen overflow-y-auto shadow">
{sidebarContent}
</div>
{/* Mobile Sidebar - overlay */}
{isOpen && (
<div className="lg:hidden fixed inset-0 z-50">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 transition-opacity"
onClick={onClose}
/>
{/* Sidebar */}
<div className="fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out">
{sidebarContent}
</div>
@@ -45,9 +45,6 @@ export const BackofficeWrapper: FC<TBackofficeWrapperProps> = ({
</h1>
<div className="flex items-center gap-x-6">
{/* <Button type="button" variant="secondary" className="max-h-full p-3">
<Icon icon="mdi:bell-outline" className="size-6" />
</Button> */}
<div className="flex items-center gap-x-6">
<div className="text-neutral-600 font-medium">
<p className="text-p3">{user?.fullname || 'Full Name'}</p>
@@ -23,7 +23,6 @@ interface DataTableProps<T extends RowData> {
columns?: ColumnDef<T, unknown>[];
pageSize?: number;
className?: string;
// server-side pagination props
manualPagination?: boolean;
pageCount?: number;
currentPage?: number;
@@ -47,7 +46,6 @@ export const DataTable = <T extends RowData>({
});
const [sorting, setSorting] = React.useState<SortingState>([]);
// Update pagination state when pageSize prop changes
React.useEffect(() => {
setPagination((prev) => ({
...prev,
@@ -55,7 +53,6 @@ export const DataTable = <T extends RowData>({
}));
}, [pageSize]);
// Reset pagination when data changes to prevent out-of-bounds errors
React.useEffect(() => {
if (data.length > 0) {
setPagination((prev) => ({
@@ -65,11 +62,9 @@ export const DataTable = <T extends RowData>({
}
}, [data.length]);
// Memoize data and columns to prevent unnecessary re-renders
const memoizedData = React.useMemo(() => data, [data]);
const memoizedColumns = React.useMemo(() => columns, [columns]);
// Memoize table configuration to prevent recreation on every render
const tableConfig = React.useMemo(() => {
const config: TableOptions<T> = {
data: memoizedData,
@@ -84,7 +79,6 @@ export const DataTable = <T extends RowData>({
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
// server-side pagination config
manualPagination,
pageCount: manualPagination ? pageCount : undefined,
};
@@ -99,11 +93,9 @@ export const DataTable = <T extends RowData>({
pageCount,
]);
// Prefer external table instance if provided; otherwise create an internal one
const internalTable = useReactTable(tableConfig);
const t = table ?? internalTable;
// Handle empty data state
const isEmpty = t.getRowModel().rows.length === 0;
return (
@@ -186,7 +178,6 @@ export const DataTable = <T extends RowData>({
</table>
</div>
{manualPagination && onPageChange && pageCount ? (
// Server-side pagination controls with numbered pages
<div className="flex items-center justify-center gap-10">
<button
className="disabled:opacity-50 cursor-pointer"
@@ -211,7 +202,6 @@ export const DataTable = <T extends RowData>({
<div className="flex gap-4 items-baseline">
{pageCount <= 8 ? (
// Show all pages if 8 or fewer
Array.from({ length: pageCount }, (_, index) => (
<button
key={index}
@@ -226,7 +216,6 @@ export const DataTable = <T extends RowData>({
</button>
))
) : (
// Show ellipsis for many pages
<>
<button
onClick={() => onPageChange(1)}
@@ -294,7 +283,6 @@ export const DataTable = <T extends RowData>({
</button>
</div>
) : (
// Client-side pagination (default)
<Pagination table={t} />
)}
</div>
+2 -1
View File
@@ -2,7 +2,8 @@ import { MenuOutlined } from '@ant-design/icons';
import { FC, ReactElement, useState } from 'react';
import { Link } from 'react-router-dom';
import { Button } from '../../atoms/button';
import { useModalLogin, useSession } from '@imphnen-frontend-service/utils';
import { useModalLogin } from '@imphnen-frontend-service/utils';
import { useSession } from '@imphnen-frontend-service/service';
export const Navbar: FC = (): ReactElement => {
const { session, signOut, isAuthenticated } = useSession();