Compare commits

..

5 Commits

Author SHA1 Message Date
01a15210ea Merge pull request 'feat/hero' (#9) from feat/hero into main
All checks were successful
Sync to GitHub / sync (push) Successful in 8s
Reviewed-on: #9
2026-03-03 14:01:42 +07:00
29f2d3fa59 🔧 chore: replace dummy data with real data
All checks were successful
Integration Tests / integration-tests (pull_request) Successful in 1m4s
2026-03-03 13:58:11 +07:00
2f9fef54ff 💄 style: adjust UI for image-only banner 2026-03-01 21:55:24 +07:00
119e0f447c feat: add base banner elements 2026-03-01 21:40:14 +07:00
24ec3588d5 🚧 wip: add base hero section component 2026-02-28 14:33:39 +07:00
11 changed files with 257 additions and 20 deletions

View File

@ -17,6 +17,7 @@
"react-dom": "19.2.3",
"shadcn": "^3.6.3",
"sonner": "^2.0.7",
"swiper": "^12.1.2",
"tailwind-merge": "^3.4.0",
"tw-animate-css": "^1.4.0",
"ua-parser-js": "^2.0.8",
@ -1328,6 +1329,8 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"swiper": ["swiper@12.1.2", "", {}, "sha512-4gILrI3vXZqoZh71I1PALqukCFgk+gpOwe1tOvz5uE9kHtl2gTDzmYflYCwWvR4LOvCrJi6UEEU+gnuW5BtkgQ=="],
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],

View File

@ -1,7 +1,11 @@
"use client";
import Hero from "./sections/Hero/wrapper";
const HomeIndex = () => {
return <div className="text-center w-full">HomePage</div>;
return (
<div className="w-full pt-4">
<Hero />
</div>
);
};
export default HomeIndex;

View File

@ -0,0 +1,118 @@
"use client";
import "swiper/css";
import { Badge } from "@/shared/libs/shadcn/ui/badge";
import { Button } from "@/shared/libs/shadcn/ui/button";
import { useRouter } from "next/navigation";
import { Autoplay, Navigation, Pagination } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
export interface HeroSwiperProps {
data: {
id: string;
isClickable: boolean;
title: string;
tags: string[];
description: string;
buttonContent: string;
buttonLink: string;
imageUrl: string;
startDate: string;
endDate: string;
}[];
}
const HeroSwiper = (props: HeroSwiperProps) => {
const router = useRouter();
return (
<div className="h-full rounded-lg overflow-hidden">
<Swiper
spaceBetween={0}
slidesPerView={1}
onSlideChange={() => console.log("slide change")}
onSwiper={(swiper) => console.log(swiper)}
className="h-full"
autoplay={{ delay: 5000, disableOnInteraction: false }}
modules={[Autoplay, Pagination, Navigation]}
>
{props.data.map((slide) =>
slide.imageUrl ? (
// Slide with image background
<SwiperSlide key={slide.id} className="relative overflow-hidden">
<img
src={slide.imageUrl}
alt={slide.title}
className="absolute top-0 left-0 z-0 object-cover w-full h-full opacity-80"
/>
{slide.title && slide.description && (
<div
className="absolute top-0 left-0 z-10 h-full w-full py-16 px-20"
style={{
background:
"linear-gradient(90deg,rgba(0, 0, 0, 0.64) 0%, rgba(0, 0, 0, 0.42) 46%, rgba(0, 0, 0, 0) 100%)",
}}
>
<h1 className="text-6xl font-semibold tracking-tight">
{slide.title}
</h1>
<div className="mt-4 flex gap-1.5">
{slide.tags.map((tag) => (
<Badge
className="bg-neutral-200 text-neutral-800"
key={tag}
>
{tag}
</Badge>
))}
</div>
<p className="mt-4 font-medium text-base max-w-[40vw] line-clamp-6">
{slide.description}
</p>
{slide.isClickable && (
<Button
size="lg"
onClick={() => router.push(slide.buttonLink)}
className="mt-6"
>
{slide.buttonContent}
</Button>
)}
</div>
)}
</SwiperSlide>
) : (
// Fallback for slides without image
<SwiperSlide
key={slide.id}
className="relative overflow-hidden bg-neutral-800 flex flex-col items-center text-center pt-18"
>
<h1 className="text-6xl font-semibold tracking-tight">
{slide.title}
</h1>
<div className="mt-4 flex justify-center gap-1.5">
{slide.tags.map((tag) => (
<Badge className="bg-neutral-200 text-neutral-800" key={tag}>
{tag}
</Badge>
))}
</div>
<p className="mt-4 font-medium text-base max-w-[40vw] mx-auto">
{slide.description}
</p>
{slide.isClickable && (
<Button
size="lg"
onClick={() => router.push(slide.buttonLink)}
className="mt-6"
>
{slide.buttonContent}
</Button>
)}
</SwiperSlide>
),
)}
</Swiper>
</div>
);
};
export default HeroSwiper;

View File

@ -0,0 +1,17 @@
import { backendFetch, BackendResponse } from "@/shared/helpers/backendFetch";
import HeroSwiper, { HeroSwiperProps } from "./components/Swiper";
const HeroMain = async () => {
const testing = async () => {
return (await backendFetch("hero-banner")) as BackendResponse<
HeroSwiperProps["data"]
>;
};
const response = await testing();
if (!response.data) return <div></div>;
return <HeroSwiper data={response.data} />;
};
export default HeroMain;

View File

@ -0,0 +1,8 @@
import { Skeleton } from "@/shared/libs/shadcn/ui/skeleton";
import React from "react";
const HeroSkeleton = () => {
return <Skeleton className="w-full h-full" />;
};
export default HeroSkeleton;

View File

@ -0,0 +1,15 @@
import { Suspense } from "react";
import HeroSkeleton from "./skeleton";
import HeroMain from "./main";
const Hero = () => {
return (
<div className="h-120 w-full">
<Suspense fallback={<HeroSkeleton />}>
<HeroMain />
</Suspense>
</div>
);
};
export default Hero;

View File

@ -21,6 +21,7 @@
"react-dom": "19.2.3",
"shadcn": "^3.6.3",
"sonner": "^2.0.7",
"swiper": "^12.1.2",
"tailwind-merge": "^3.4.0",
"tw-animate-css": "^1.4.0",
"ua-parser-js": "^2.0.8"

View File

@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/shared/libs/shadcn/lib/utils"
const badgeVariants = cva(
"h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -1,29 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/shared/libs/shadcn/lib/utils"
import { cn } from "@/shared/libs/shadcn/lib/utils";
const buttonVariants = cva(
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 cursor-pointer [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground shadow-xs",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground shadow-xs",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
destructive:
"bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
default:
"h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-9",
"icon-xs": "size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
"icon-lg": "size-10",
},
},
@ -31,8 +38,8 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
function Button({
className,
@ -42,9 +49,9 @@ function Button({
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button"
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
@ -54,7 +61,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
}
export { Button, buttonVariants }
export { Button, buttonVariants };

View File

@ -0,0 +1,13 @@
import { cn } from "@/shared/libs/shadcn/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

2
shared/types/swiper.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
declare module "swiper/css";
declare module "swiper/css/*";