Skip to content

Commit 2d13bfa

Browse files
authored
Merge pull request #88 from hammercode-dev/feat/user-profile
[FEAT] - UI user profile
2 parents 0d0abdd + 6491854 commit 2d13bfa

10 files changed

Lines changed: 776 additions & 24 deletions

File tree

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@
2626
"@radix-ui/react-dialog": "^1.1.14",
2727
"@radix-ui/react-dropdown-menu": "^2.1.15",
2828
"@radix-ui/react-label": "^2.1.6",
29+
"@radix-ui/react-popover": "^1.1.15",
2930
"@radix-ui/react-select": "^2.1.1",
3031
"@radix-ui/react-separator": "^1.1.7",
3132
"@radix-ui/react-slot": "^1.2.3",
33+
"@radix-ui/react-tabs": "^1.1.13",
3234
"@radix-ui/react-tooltip": "^1.2.7",
3335
"@tanstack/react-query": "^5.85.5",
3436
"@tanstack/react-table": "^8.21.3",
@@ -45,6 +47,7 @@
4547
"class-variance-authority": "^0.7.1",
4648
"clsx": "^2.1.1",
4749
"cookie": "^1.0.2",
50+
"date-fns": "^4.1.0",
4851
"embla-carousel-autoplay": "^8.2.0",
4952
"embla-carousel-react": "^8.2.0",
5053
"js-cookie": "^3.0.5",
@@ -57,8 +60,9 @@
5760
"next-themes": "^0.4.6",
5861
"prettier-plugin-tailwindcss": "^0.6.11",
5962
"react": "19.1.0",
63+
"react-day-picker": "^9.9.0",
6064
"react-dom": "19.1.0",
61-
"react-hook-form": "^7.56.2",
65+
"react-hook-form": "^7.62.0",
6266
"rehype-pretty-code": "^0.14.1",
6367
"remark-gfm": "^4.0.1",
6468
"shiki": "^3.7.0",

pnpm-lock.yaml

Lines changed: 248 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/[locale]/(public)/(user)/layout.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ import { Separator } from "@/components/ui/Separator";
77
import { Link } from "@/lib/navigation";
88
import { useAuthUser } from "@/components/hooks/UseAuthUser";
99
import ProtectedRoute from "@/components/layout/ProtectedRoute";
10+
import { usePathname } from "@/lib/navigation";
1011

1112
interface UserLayoutProps {
1213
children: React.ReactNode;
1314
}
1415

1516
export default function UserLayout({ children }: UserLayoutProps) {
1617
const { user } = useAuthUser();
18+
const pathname = usePathname();
1719

1820
return (
1921
<ProtectedRoute>
@@ -33,11 +35,24 @@ export default function UserLayout({ children }: UserLayoutProps) {
3335

3436
<Separator />
3537

38+
{/* TODO: Refactor to use constant list for better maintainability
39+
Should create a navigationItems array with { href, label, icon, isActive }
40+
instead of hardcoding each link with pathname checks */}
3641
<nav className="flex flex-col gap-3 text-sm">
37-
<Link href="/my-events" className="flex items-center gap-2 text-gray-800 transition hover:text-blue-600">
42+
<Link
43+
href="/my-events"
44+
className={`flex items-center gap-2 transition hover:text-blue-600 ${
45+
pathname.includes("/my-events") ? "text-hmc-base-blue" : "text-foreground"
46+
}`}
47+
>
3848
<Calendar1 size={16} /> My Events
3949
</Link>
40-
<Link href="/profile" className="flex items-center gap-2 text-gray-800 transition hover:text-blue-600">
50+
<Link
51+
href="/profile"
52+
className={`flex items-center gap-2 transition hover:text-blue-600 ${
53+
pathname.includes("/profile") ? "text-hmc-base-blue" : "text-foreground"
54+
}`}
55+
>
4156
<User size={16} /> Profil
4257
</Link>
4358
</nav>

src/app/globals.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
--hmc-base-blue: oklch(0.671 0.256 246.882);
1414
--hmc-base-purple: oklch(0.511 0.233 264.705);
15-
--hmc-base-darkblue: oklch(0.208 0.042 265.755);
15+
--hmc-base-darkblue: oklch(0.334 0.0933 255.46);
1616
--hmc-base-lightblue: oklch(0.671 0.256 246.882);
1717

1818
--card: oklch(1 0 0);
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
5+
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
6+
7+
import { cn } from "@/lib/utils";
8+
import { Button, buttonVariants } from "@/components/ui/Button";
9+
10+
function Calendar({
11+
className,
12+
classNames,
13+
showOutsideDays = true,
14+
captionLayout = "label",
15+
buttonVariant = "ghost",
16+
formatters,
17+
components,
18+
...props
19+
}: React.ComponentProps<typeof DayPicker> & {
20+
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
21+
}) {
22+
const defaultClassNames = getDefaultClassNames();
23+
24+
return (
25+
<DayPicker
26+
showOutsideDays={showOutsideDays}
27+
className={cn(
28+
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
29+
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
30+
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
31+
className
32+
)}
33+
captionLayout={captionLayout}
34+
formatters={{
35+
formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
36+
...formatters,
37+
}}
38+
classNames={{
39+
root: cn("w-fit", defaultClassNames.root),
40+
months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
41+
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
42+
nav: cn("flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between", defaultClassNames.nav),
43+
button_previous: cn(
44+
buttonVariants({ variant: buttonVariant }),
45+
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
46+
defaultClassNames.button_previous
47+
),
48+
button_next: cn(
49+
buttonVariants({ variant: buttonVariant }),
50+
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
51+
defaultClassNames.button_next
52+
),
53+
month_caption: cn(
54+
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
55+
defaultClassNames.month_caption
56+
),
57+
dropdowns: cn(
58+
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
59+
defaultClassNames.dropdowns
60+
),
61+
dropdown_root: cn(
62+
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
63+
defaultClassNames.dropdown_root
64+
),
65+
dropdown: cn("absolute bg-popover inset-0 opacity-0", defaultClassNames.dropdown),
66+
caption_label: cn(
67+
"select-none font-medium",
68+
captionLayout === "label"
69+
? "text-sm"
70+
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
71+
defaultClassNames.caption_label
72+
),
73+
table: "w-full border-collapse",
74+
weekdays: cn("flex", defaultClassNames.weekdays),
75+
weekday: cn(
76+
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
77+
defaultClassNames.weekday
78+
),
79+
week: cn("flex w-full mt-2", defaultClassNames.week),
80+
week_number_header: cn("select-none w-(--cell-size)", defaultClassNames.week_number_header),
81+
week_number: cn("text-[0.8rem] select-none text-muted-foreground", defaultClassNames.week_number),
82+
day: cn(
83+
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
84+
defaultClassNames.day
85+
),
86+
range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
87+
range_middle: cn("rounded-none", defaultClassNames.range_middle),
88+
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
89+
today: cn(
90+
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
91+
defaultClassNames.today
92+
),
93+
outside: cn("text-muted-foreground aria-selected:text-muted-foreground", defaultClassNames.outside),
94+
disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
95+
hidden: cn("invisible", defaultClassNames.hidden),
96+
...classNames,
97+
}}
98+
components={{
99+
Root: ({ className, rootRef, ...props }) => {
100+
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
101+
},
102+
Chevron: ({ className, orientation, ...props }) => {
103+
if (orientation === "left") {
104+
return <ChevronLeftIcon className={cn("size-4", className)} {...props} />;
105+
}
106+
107+
if (orientation === "right") {
108+
return <ChevronRightIcon className={cn("size-4", className)} {...props} />;
109+
}
110+
111+
return <ChevronDownIcon className={cn("size-4", className)} {...props} />;
112+
},
113+
DayButton: CalendarDayButton,
114+
WeekNumber: ({ children, ...props }) => {
115+
return (
116+
<td {...props}>
117+
<div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div>
118+
</td>
119+
);
120+
},
121+
...components,
122+
}}
123+
{...props}
124+
/>
125+
);
126+
}
127+
128+
function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) {
129+
const defaultClassNames = getDefaultClassNames();
130+
131+
const ref = React.useRef<HTMLButtonElement>(null);
132+
React.useEffect(() => {
133+
if (modifiers.focused) ref.current?.focus();
134+
}, [modifiers.focused]);
135+
136+
return (
137+
<Button
138+
ref={ref}
139+
variant="ghost"
140+
size="icon"
141+
data-day={day.date.toLocaleDateString()}
142+
data-selected-single={
143+
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
144+
}
145+
data-range-start={modifiers.range_start}
146+
data-range-end={modifiers.range_end}
147+
data-range-middle={modifiers.range_middle}
148+
className={cn(
149+
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
150+
defaultClassNames.day,
151+
className
152+
)}
153+
{...props}
154+
/>
155+
);
156+
}
157+
158+
export { Calendar, CalendarDayButton };
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
import * as PopoverPrimitive from "@radix-ui/react-popover";
5+
6+
import { cn } from "@/lib/utils";
7+
8+
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
9+
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
10+
}
11+
12+
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
13+
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
14+
}
15+
16+
function PopoverContent({
17+
className,
18+
align = "center",
19+
sideOffset = 4,
20+
...props
21+
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
22+
return (
23+
<PopoverPrimitive.Portal>
24+
<PopoverPrimitive.Content
25+
data-slot="popover-content"
26+
align={align}
27+
sideOffset={sideOffset}
28+
className={cn(
29+
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
30+
className
31+
)}
32+
{...props}
33+
/>
34+
</PopoverPrimitive.Portal>
35+
);
36+
}
37+
38+
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
39+
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
40+
}
41+
42+
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

src/components/ui/Tabs/index.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
import * as TabsPrimitive from "@radix-ui/react-tabs";
5+
6+
import { cn } from "@/lib/utils";
7+
8+
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
9+
return <TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />;
10+
}
11+
12+
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
13+
return (
14+
<TabsPrimitive.List
15+
data-slot="tabs-list"
16+
className={cn(
17+
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
18+
className
19+
)}
20+
{...props}
21+
/>
22+
);
23+
}
24+
25+
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
26+
return (
27+
<TabsPrimitive.Trigger
28+
data-slot="tabs-trigger"
29+
className={cn(
30+
"data-[state=active]:bg-hmc-base-darkblue dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-hmc-base-blue text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-white data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
31+
className
32+
)}
33+
{...props}
34+
/>
35+
);
36+
}
37+
38+
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
39+
return <TabsPrimitive.Content data-slot="tabs-content" className={cn("flex-1 outline-none", className)} {...props} />;
40+
}
41+
42+
export { Tabs, TabsList, TabsTrigger, TabsContent };

src/domains/Profile.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,16 @@ export const UserSchema = z.object({
1919
});
2020

2121
export type UserType = z.infer<typeof UserSchema>;
22+
23+
export const profileFormSchema = z.object({
24+
fullname: z.string().min(1, "Fullname is required"),
25+
date_of_birth: z.string().min(1, "Date of birth is required"),
26+
gender: z.enum(["Male", "Female"], { required_error: "Please select a gender" }),
27+
phone_number: z.string().min(1, "Phone number is required"),
28+
address: z.string().min(1, "Address is required"),
29+
github: z.string().url("Please enter a valid GitHub URL").optional().or(z.literal("")),
30+
linkedin: z.string().url("Please enter a valid LinkedIn URL").optional().or(z.literal("")),
31+
personal_web: z.string().url("Please enter a valid personal website URL").optional().or(z.literal("")),
32+
});
33+
34+
export type ProfileFormType = z.infer<typeof profileFormSchema>;

0 commit comments

Comments
 (0)