create-landing-app 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/prompts.js +7 -3
  2. package/dist/scaffold.js +5 -2
  3. package/package.json +1 -1
  4. package/templates/nextjs/base/.dockerignore +6 -0
  5. package/templates/nextjs/base/.editorconfig +15 -0
  6. package/templates/nextjs/base/.env.example +9 -2
  7. package/templates/nextjs/base/.husky/pre-push +8 -10
  8. package/templates/nextjs/base/CLAUDE.md +169 -0
  9. package/templates/nextjs/base/Dockerfile +3 -9
  10. package/templates/nextjs/base/Makefile +25 -0
  11. package/templates/nextjs/base/app/layout.tsx +6 -9
  12. package/templates/nextjs/base/app/sitemap.ts +15 -0
  13. package/templates/nextjs/base/commitlint.config.mjs +6 -22
  14. package/templates/nextjs/base/components/navs/navbar-mobile.tsx +60 -27
  15. package/templates/nextjs/base/components/navs/navbar.tsx +9 -2
  16. package/templates/nextjs/base/components/ui/checkbox.tsx +26 -0
  17. package/templates/nextjs/base/components/ui/input.tsx +21 -0
  18. package/templates/nextjs/base/components/ui/radio-group.tsx +36 -0
  19. package/templates/nextjs/base/components/ui/select.tsx +139 -0
  20. package/templates/nextjs/base/components/ui/sheet.tsx +139 -0
  21. package/templates/nextjs/base/components/ui/tabs.tsx +53 -0
  22. package/templates/nextjs/base/components/ui/textarea.tsx +20 -0
  23. package/templates/nextjs/base/docker-compose.yml +9 -0
  24. package/templates/nextjs/base/eslint.config.mjs +5 -9
  25. package/templates/nextjs/base/next.config.ts +4 -0
  26. package/templates/nextjs/base/package.json +7 -4
  27. package/templates/nextjs/base/styles/theme.css +2 -0
  28. package/templates/nextjs/base/tsconfig.json +2 -2
  29. package/templates/nextjs/optional/analytics/files/components/analytics.tsx +16 -0
  30. package/templates/nextjs/optional/analytics/files/components/web-vitals.tsx +16 -0
  31. package/templates/nextjs/optional/analytics/inject/app__layout.tsx +7 -0
  32. package/templates/nextjs/optional/analytics/pkg.json +5 -0
  33. package/templates/nextjs/optional/dark-mode/files/components/theme-toggle.tsx +21 -0
  34. package/templates/nextjs/optional/dark-mode/inject/app__layout.tsx +8 -0
  35. package/templates/nextjs/optional/dark-mode/pkg.json +5 -0
  36. package/templates/nextjs/optional/i18n-dict/files/components/navs/navbar-mobile.tsx +60 -26
  37. package/templates/nextjs/optional/i18n-dict/files/components/navs/navbar.tsx +8 -2
  38. package/templates/nextjs/optional/i18n-dict/files/{middleware.ts → proxy.ts} +8 -2
  39. package/templates/nextjs/optional/i18n-dict/inject/app__layout.tsx +34 -0
  40. package/templates/nextjs/optional/sections/blog/files/app/[lang]/blogs/(list)/[category]/main-page.tsx +15 -0
  41. package/templates/nextjs/optional/sections/blog/files/app/[lang]/blogs/(list)/[category]/page.tsx +38 -0
  42. package/templates/nextjs/optional/sections/blog/files/app/[lang]/blogs/(list)/layout.tsx +28 -0
  43. package/templates/nextjs/optional/sections/blog/files/app/[lang]/blogs/detail/[slugNews]/blog-detail-view.tsx +122 -0
  44. package/templates/nextjs/optional/sections/blog/files/app/[lang]/blogs/detail/[slugNews]/page.tsx +73 -0
  45. package/templates/nextjs/optional/sections/blog/files/app/api/blogs/route.ts +14 -0
  46. package/templates/nextjs/optional/sections/blog/files/components/blogs/blog-component.tsx +58 -0
  47. package/templates/nextjs/optional/sections/blog/files/components/blogs/blog-view-desktop.tsx +121 -0
  48. package/templates/nextjs/optional/sections/blog/files/components/blogs/blog-view-mobile.tsx +90 -0
  49. package/templates/nextjs/optional/sections/blog/files/components/navs/layout-blogs.tsx +51 -0
  50. package/templates/nextjs/optional/sections/blog/files/components/sections/blog-section-view.tsx +171 -0
  51. package/templates/nextjs/optional/sections/blog/files/components/sections/blog-section.tsx +13 -174
  52. package/templates/nextjs/optional/sections/blog/files/hooks/use-mobile.ts +19 -0
  53. package/templates/nextjs/optional/sections/blog/files/lib/blog-api.ts +336 -0
  54. package/templates/nextjs/optional/sections/blog/files/lib/sanitize.ts +25 -0
  55. package/templates/nextjs/optional/sections/blog/files/styles/prose.css +40 -0
  56. package/templates/nextjs/optional/sections/blog/inject/constants__common.ts +1 -1
  57. package/templates/nextjs/optional/sections/blog/pkg.json +10 -0
@@ -11,12 +11,15 @@ import { cn } from "@/lib/utils";
11
11
  export default function Navbar() {
12
12
  const dict = useDictionary();
13
13
  const [isVisible, setIsVisible] = useState(true);
14
+ const [isScrolled, setIsScrolled] = useState(false);
14
15
  const lastScrollY = useRef(0);
15
16
 
16
17
  useEffect(() => {
17
18
  const controlNavbar = () => {
18
19
  const currentScrollY = window.scrollY;
19
20
 
21
+ setIsScrolled(currentScrollY > 10);
22
+
20
23
  if (currentScrollY < lastScrollY.current || currentScrollY < 10) {
21
24
  setIsVisible(true);
22
25
  } else {
@@ -33,8 +36,11 @@ export default function Navbar() {
33
36
  return (
34
37
  <header
35
38
  className={cn(
36
- "fixed top-0 z-50 w-full border-b border-border/40 bg-background/80 backdrop-blur-sm header-shadow transition-transform duration-300",
37
- isVisible ? "translate-y-0" : "-translate-y-[calc(100%+20px)]"
39
+ "fixed top-0 z-50 w-full transition-all duration-700 ease-in-out",
40
+ isVisible ? "translate-y-0" : "-translate-y-[calc(100%+20px)]",
41
+ isScrolled
42
+ ? "border-b border-border/40 bg-background/80 backdrop-blur-md shadow-sm"
43
+ : "border-b border-transparent bg-transparent",
38
44
  )}
39
45
  >
40
46
  <div className="content-container flex h-16 items-center justify-between">
@@ -14,12 +14,18 @@ function getLocale(request: NextRequest): string {
14
14
  return matchLocale(languages, [...i18n.locales], i18n.defaultLocale);
15
15
  }
16
16
 
17
- export function middleware(request: NextRequest) {
17
+ export function proxy(request: NextRequest) {
18
18
  const { pathname } = request.nextUrl;
19
19
  const pathnameHasLocale = i18n.locales.some(
20
20
  (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
21
21
  );
22
- if (pathnameHasLocale) return;
22
+ if (pathnameHasLocale) {
23
+ // Forward detected locale to the root layout via response header
24
+ const locale = pathname.split("/")[1];
25
+ const response = NextResponse.next();
26
+ response.headers.set("x-locale", locale);
27
+ return response;
28
+ }
23
29
 
24
30
  const locale = getLocale(request);
25
31
  request.nextUrl.pathname = `/${locale}${pathname}`;
@@ -0,0 +1,34 @@
1
+ import type { Metadata } from "next";
2
+ import { Inter } from "next/font/google";
3
+ import { headers } from "next/headers";
4
+ import { Toaster } from "@/components/ui/sonner";
5
+ import Providers from "@/components/providers";
6
+ import { createMetadata } from "@/lib/metadata";
7
+ import "./globals.css";
8
+ // __PROVIDERS_IMPORT__
9
+
10
+ const inter = Inter({ variable: "--font-inter", subsets: ["latin"] });
11
+
12
+ export const metadata: Metadata = createMetadata({
13
+ title: "__PROJECT_NAME__",
14
+ description: "A modern landing page",
15
+ });
16
+
17
+ export default async function RootLayout({ children }: { children: React.ReactNode }) {
18
+ // Locale is set by the i18n middleware via x-locale response header
19
+ const headersList = await headers();
20
+ const lang = headersList.get("x-locale") ?? "en";
21
+
22
+ return (
23
+ <html lang={lang} suppressHydrationWarning>
24
+ <body className={`${inter.variable} font-sans antialiased`}>
25
+ <Providers>
26
+ {/* __PROVIDERS_WRAP_START__ */}
27
+ {children}
28
+ {/* __PROVIDERS_WRAP_END__ */}
29
+ </Providers>
30
+ <Toaster />
31
+ </body>
32
+ </html>
33
+ );
34
+ }
@@ -0,0 +1,15 @@
1
+ "use client";
2
+ import BlogComponent from "@/components/blogs/blog-component";
3
+ import type { Blog, BlogCategory } from "@/lib/blog-api";
4
+
5
+ export default function MainPage({
6
+ category,
7
+ categories,
8
+ blogs,
9
+ }: {
10
+ category: string;
11
+ categories: BlogCategory[];
12
+ blogs: Blog[];
13
+ }) {
14
+ return <BlogComponent category={category} categories={categories} blogs={blogs} />;
15
+ }
@@ -0,0 +1,38 @@
1
+ import { getBlogCategories, getBlogsByCategory } from "@/lib/blog-api";
2
+ import { createMetadata } from "@/lib/metadata";
3
+ import { cache } from "react";
4
+ import MainPage from "./main-page";
5
+
6
+ // Cache listing pages — same window as detail pages, tune via NEXT_BLOG_REVALIDATE_SECONDS
7
+ export const revalidate = Number(process.env.NEXT_BLOG_REVALIDATE_SECONDS) || 86400;
8
+
9
+ const getCategories = cache(getBlogCategories);
10
+
11
+ export async function generateMetadata({
12
+ params,
13
+ }: {
14
+ params: Promise<{ category: string; lang: string }>;
15
+ }) {
16
+ const { category } = await params;
17
+ const categories = await getCategories();
18
+ const matched = categories.find((c) => c.slug === category);
19
+
20
+ return createMetadata({
21
+ title: matched ? `Blog | ${matched.name}` : "Blog",
22
+ description: "Insights, tutorials, and updates from our team.",
23
+ path: `/blogs/${category}`,
24
+ });
25
+ }
26
+
27
+ export default async function Page({
28
+ params,
29
+ }: {
30
+ params: Promise<{ category: string; lang: string }>;
31
+ }) {
32
+ const { category } = await params;
33
+ const [categories, { blogs }] = await Promise.all([
34
+ getCategories(),
35
+ getBlogsByCategory({ category, pageSize: 9999 }),
36
+ ]);
37
+ return <MainPage category={category} categories={categories} blogs={blogs} />;
38
+ }
@@ -0,0 +1,28 @@
1
+ import Navbar from "@/components/navs/navbar";
2
+ import FooterSection from "@/components/sections/footer-section";
3
+ import LayoutBlogs from "@/components/navs/layout-blogs";
4
+ import { getBlogCategories } from "@/lib/blog-api";
5
+
6
+ // Nav categories are static data — cache window tunable via NEXT_BLOG_REVALIDATE_SECONDS
7
+ export const revalidate = Number(process.env.NEXT_BLOG_REVALIDATE_SECONDS) || 86400;
8
+
9
+ export default async function Layout({
10
+ children,
11
+ params,
12
+ }: {
13
+ children: React.ReactNode;
14
+ params: Promise<{ lang: string }>;
15
+ }) {
16
+ // params.lang available for future i18n label lookup
17
+ await params;
18
+
19
+ const categories = await getBlogCategories();
20
+
21
+ return (
22
+ <>
23
+ <Navbar />
24
+ <LayoutBlogs categories={categories}>{children}</LayoutBlogs>
25
+ <FooterSection />
26
+ </>
27
+ );
28
+ }
@@ -0,0 +1,122 @@
1
+ "use client";
2
+ import Link from "next/link";
3
+ import { useMemo } from "react";
4
+ import { motion } from "motion/react";
5
+ import { Button } from "@/components/ui/button";
6
+ import type { Blog, BlogCategory } from "@/lib/blog-api";
7
+ import { sanitizeBlogContent } from "@/lib/sanitize";
8
+ import "@/styles/prose.css";
9
+
10
+ function CategoryBadges({ categoryIds, categories = [] }: { categoryIds?: string[]; categories?: BlogCategory[] }) {
11
+ if (!categoryIds?.length) return null;
12
+ return (
13
+ <div className="flex flex-wrap gap-1">
14
+ {categoryIds.map((id) => {
15
+ const name = categories.find((c) => c.id === id)?.name ?? id;
16
+ return (
17
+ <Button key={id} className="h-[22px] w-fit rounded-[4px] bg-primary text-xs text-primary-foreground hover:bg-primary/80">
18
+ {name}
19
+ </Button>
20
+ );
21
+ })}
22
+ </div>
23
+ );
24
+ }
25
+
26
+ // Sidebar: related blogs in the same category
27
+ function RelatedBlogs({ blogs, dateFormatter, categories }: { blogs: Blog[]; dateFormatter: Intl.DateTimeFormat; categories: BlogCategory[] }) {
28
+ if (!blogs.length) return null;
29
+
30
+ return (
31
+ <aside className="mt-40 col-span-4 flex flex-col gap-4 max-xl:col-span-1 max-xl:mt-0">
32
+ <h3 className="text-2xl font-semibold text-primary">Related Blogs</h3>
33
+ <div className="flex flex-col gap-6">
34
+ {blogs.slice(0, 5).map((blog) => (
35
+ <Link
36
+ key={blog.slug}
37
+ href={`/blogs/detail/${blog.slug}`}
38
+ className="flex items-start gap-4 hover:opacity-80 transition-opacity"
39
+ >
40
+ <div className="h-[91px] w-[118px] flex-shrink-0">
41
+ {blog.thumbnail ? (
42
+ <img
43
+ src={blog.thumbnail}
44
+ alt={blog.name}
45
+ loading="lazy"
46
+ className="h-full w-full object-cover rounded-[10px]"
47
+ />
48
+ ) : (
49
+ <div className="h-full w-full bg-muted rounded-[10px]" />
50
+ )}
51
+ </div>
52
+ <div className="flex flex-col gap-1">
53
+ <CategoryBadges categoryIds={blog.categoryIds} categories={categories} />
54
+ <span className="text-sm font-semibold leading-6 text-primary line-clamp-2">
55
+ {blog.name}
56
+ </span>
57
+ <time dateTime={blog.publishedAt} className="text-sm text-muted-foreground">
58
+ {dateFormatter.format(new Date(blog.publishedAt))}
59
+ </time>
60
+ </div>
61
+ </Link>
62
+ ))}
63
+ </div>
64
+ </aside>
65
+ );
66
+ }
67
+
68
+ type BlogDetailViewProps = {
69
+ blog: Blog;
70
+ relatedBlogs: Blog[];
71
+ categories: BlogCategory[];
72
+ lang: string;
73
+ };
74
+
75
+ export function BlogDetailView({ blog, relatedBlogs, categories, lang }: BlogDetailViewProps) {
76
+ // Locale-aware date formatter — useMemo avoids recreation on every render
77
+ const dateFormatter = useMemo(
78
+ () => new Intl.DateTimeFormat(lang === "en" ? "en-US" : "vi-VN", {
79
+ day: "2-digit", month: "2-digit", year: "numeric",
80
+ }),
81
+ [lang]
82
+ );
83
+
84
+ const htmlContent = typeof blog.content === "string"
85
+ ? blog.content
86
+ : blog.content?.htmlContent ?? "";
87
+
88
+ return (
89
+ <motion.div
90
+ viewport={{ once: true }}
91
+ initial={{ opacity: 0, y: 40 }}
92
+ whileInView={{ opacity: 1, y: 0 }}
93
+ transition={{ duration: 1.1 }}
94
+ className="py-20 grid grid-cols-12 gap-8 max-xl:grid-cols-1 max-md:mt-9"
95
+ >
96
+ {/* Main content */}
97
+ <article className="col-span-8 max-xl:col-span-1 flex flex-col gap-6">
98
+ <div className="flex flex-col gap-3">
99
+ <CategoryBadges categoryIds={blog.categoryIds} categories={categories} />
100
+ <h1 className="text-3xl font-semibold leading-tight text-primary">{blog.name}</h1>
101
+ <time dateTime={blog.publishedAt} className="text-sm text-muted-foreground">
102
+ {dateFormatter.format(new Date(blog.publishedAt))}
103
+ </time>
104
+ <div className="rounded-[4px] bg-muted p-4">
105
+ <p className="text-sm leading-5 text-primary">{blog.description}</p>
106
+ </div>
107
+ </div>
108
+ {blog.thumbnail && (
109
+ <img src={blog.thumbnail} alt={blog.name} className="w-full rounded-2xl object-cover max-h-[420px]" />
110
+ )}
111
+ {htmlContent && (
112
+ <div
113
+ className="ProseMirror"
114
+ dangerouslySetInnerHTML={{ __html: sanitizeBlogContent(htmlContent) }}
115
+ />
116
+ )}
117
+ </article>
118
+
119
+ <RelatedBlogs blogs={relatedBlogs} dateFormatter={dateFormatter} categories={categories} />
120
+ </motion.div>
121
+ );
122
+ }
@@ -0,0 +1,73 @@
1
+ import { notFound } from "next/navigation";
2
+ import { cache } from "react";
3
+ import { getBlogBySlug, getRelatedBlogs, getBlogCategories, getBlogs } from "@/lib/blog-api";
4
+ import { createMetadata } from "@/lib/metadata";
5
+ import { BlogDetailView } from "./blog-detail-view";
6
+ import Navbar from "@/components/navs/navbar";
7
+ import FooterSection from "@/components/sections/footer-section";
8
+
9
+ // Cache this page's HTML — serve stale + regenerate in background after expiry
10
+ export const revalidate = Number(process.env.NEXT_BLOG_REVALIDATE_SECONDS) || 86400;
11
+
12
+ // Pre-render top 20 blog detail pages at build time; remaining slugs render on first visit then get cached
13
+ export async function generateStaticParams() {
14
+ try {
15
+ const blogs = await getBlogs(20);
16
+ return blogs.map((blog) => ({ slugNews: blog.slug }));
17
+ } catch {
18
+ return []; // build succeeds even if API is down
19
+ }
20
+ }
21
+
22
+ // Deduplicate API calls shared between generateMetadata and Page
23
+ const fetchBlog = cache(getBlogBySlug);
24
+
25
+ export async function generateMetadata({
26
+ params,
27
+ }: {
28
+ params: Promise<{ slugNews: string; lang: string }>;
29
+ }) {
30
+ const { slugNews } = await params;
31
+ const blog = await fetchBlog(slugNews);
32
+
33
+ if (!blog) {
34
+ return createMetadata({ title: "Blog", description: "Blog" });
35
+ }
36
+
37
+ return createMetadata({
38
+ title: blog.name,
39
+ description: blog.description,
40
+ image: blog.thumbnail,
41
+ path: `/blogs/detail/${blog.slug}`,
42
+ });
43
+ }
44
+
45
+ // RSC: fetch blog + related, guard, pass as plain props to client component
46
+ export default async function BlogDetailPage({
47
+ params,
48
+ }: {
49
+ params: Promise<{ slugNews: string; lang: string }>;
50
+ }) {
51
+ const { slugNews, lang } = await params;
52
+
53
+ const blog = await fetchBlog(slugNews);
54
+
55
+ // Guard — must happen server-side before anything renders
56
+ if (!blog) notFound();
57
+
58
+ // Parallel: categories + related (related needs blog.categoryIds[0] from above)
59
+ const [categories, relatedBlogs] = await Promise.all([
60
+ getBlogCategories(),
61
+ getRelatedBlogs(blog.categoryIds?.[0] ?? "", blog.id ?? ""),
62
+ ]);
63
+
64
+ return (
65
+ <>
66
+ <Navbar />
67
+ <main className="content-container">
68
+ <BlogDetailView blog={blog} relatedBlogs={relatedBlogs} categories={categories} lang={lang} />
69
+ </main>
70
+ <FooterSection />
71
+ </>
72
+ );
73
+ }
@@ -0,0 +1,14 @@
1
+ import { type NextRequest, NextResponse } from "next/server";
2
+ import { getBlogsByCategory } from "@/lib/blog-api";
3
+
4
+ // GET /api/blogs?category=all&page=1&pageSize=9999
5
+ export async function GET(request: NextRequest) {
6
+ const { searchParams } = request.nextUrl;
7
+ const category = searchParams.get("category") ?? "all";
8
+ const page = parseInt(searchParams.get("page") ?? "1", 10);
9
+ const pageSize = parseInt(searchParams.get("pageSize") ?? "9999", 10);
10
+
11
+ const blogs = await getBlogsByCategory({ category, page, pageSize });
12
+
13
+ return NextResponse.json({ blogs });
14
+ }
@@ -0,0 +1,58 @@
1
+ "use client";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { useIsMobile } from "@/hooks/use-mobile";
4
+ import type { Blog, BlogCategory } from "@/lib/blog-api";
5
+ import BlogViewDesktop from "./blog-view-desktop";
6
+ import { BlogViewMobile } from "./blog-view-mobile";
7
+
8
+ async function fetchBlogs(category: string): Promise<Blog[]> {
9
+ const params = new URLSearchParams({ category, pageSize: "9999" });
10
+ const res = await fetch(`/api/blogs?${params}`);
11
+ if (!res.ok) throw new Error("Failed to fetch blogs");
12
+ const data = await res.json();
13
+ return data.blogs;
14
+ }
15
+
16
+ export default function BlogComponent({ category, categories = [] }: { category: string; categories?: BlogCategory[] }) {
17
+ const isMobile = useIsMobile();
18
+
19
+ const { data, isError, isPending } = useQuery<Blog[]>({
20
+ queryKey: ["/blogs/list-all", { category }],
21
+ queryFn: () => fetchBlogs(category),
22
+ retry: 0,
23
+ refetchOnWindowFocus: false,
24
+ });
25
+
26
+ if (isPending) {
27
+ return (
28
+ <div className="grid grid-cols-3 gap-8 max-lg:grid-cols-2 max-sm:grid-cols-1">
29
+ {Array.from({ length: 6 }).map((_, i) => (
30
+ <div key={i} className="flex flex-col gap-3 animate-pulse">
31
+ <div className="h-[200px] w-full rounded-xl bg-muted" />
32
+ <div className="h-4 w-20 rounded bg-muted" />
33
+ <div className="h-5 w-full rounded bg-muted" />
34
+ <div className="h-4 w-3/4 rounded bg-muted" />
35
+ </div>
36
+ ))}
37
+ </div>
38
+ );
39
+ }
40
+
41
+ if (isError) {
42
+ return (
43
+ <div className="flex min-h-[500px] items-center justify-center text-center text-muted-foreground">
44
+ Failed to load blogs. Please try again.
45
+ </div>
46
+ );
47
+ }
48
+
49
+ if (!data || data.length === 0) {
50
+ return (
51
+ <div className="flex min-h-[500px] items-center justify-center text-center text-2xl font-bold text-primary">
52
+ No blogs yet
53
+ </div>
54
+ );
55
+ }
56
+
57
+ return isMobile ? <BlogViewMobile data={data} categories={categories} /> : <BlogViewDesktop data={data} categories={categories} />;
58
+ }
@@ -0,0 +1,121 @@
1
+ "use client";
2
+ import Link from "next/link";
3
+ import { motion } from "motion/react";
4
+ import { Button } from "@/components/ui/button";
5
+ import type { Blog, BlogCategory } from "@/lib/blog-api";
6
+
7
+ function formatDate(date: string) {
8
+ return new Date(date).toLocaleDateString("en-GB");
9
+ }
10
+
11
+ function Thumbnail({ src, alt, className }: { src?: string; alt?: string; className: string }) {
12
+ return src ? (
13
+ <img src={src} alt={alt ?? ""} loading="lazy" className={`h-full w-full object-cover ${className}`} />
14
+ ) : (
15
+ <div className={`h-full w-full bg-muted ${className}`} />
16
+ );
17
+ }
18
+
19
+ function CategoryBadges({ categoryIds, categories }: { categoryIds?: string[]; categories: BlogCategory[] }) {
20
+ if (!categoryIds?.length) return null;
21
+ return (
22
+ <div className="flex flex-wrap gap-1">
23
+ {categoryIds.map((id) => (
24
+ <Button key={id} className="h-[22px] w-fit rounded-[4px] bg-primary px-2 text-xs text-primary-foreground hover:bg-primary/80">
25
+ {categories.find((c) => c.id === id)?.name ?? id}
26
+ </Button>
27
+ ))}
28
+ </div>
29
+ );
30
+ }
31
+
32
+ export default function BlogViewDesktop({ data, categories = [] }: { data: Blog[]; categories?: BlogCategory[] }) {
33
+ const [featured, ...rest] = data;
34
+
35
+ return (
36
+ <motion.div
37
+ viewport={{ once: true }}
38
+ initial={{ opacity: 0, y: 40 }}
39
+ whileInView={{ opacity: 1, y: 0 }}
40
+ transition={{ duration: 1.1 }}
41
+ className="flex flex-col gap-[128px] pb-20"
42
+ >
43
+ {/* Featured + Sidebar */}
44
+ <div className="flex justify-between gap-[29px] max-lg:gap-5">
45
+ {/* Featured Blog (Left) */}
46
+ <div className={`flex flex-col gap-2 ${data.length <= 1 ? "w-full" : "w-1/2"}`}>
47
+ <Link
48
+ href={`/blogs/detail/${featured.slug}`}
49
+ className={`w-full ${data.length <= 1 ? "h-[500px]" : "h-[320px]"}`}
50
+ >
51
+ <Thumbnail src={featured.thumbnail} alt={featured.name} className="rounded-[16px]" />
52
+ </Link>
53
+ <div className="mt-1">
54
+ <CategoryBadges categoryIds={featured.categoryIds} categories={categories} />
55
+ </div>
56
+ <Link
57
+ href={`/blogs/detail/${featured.slug}`}
58
+ className="line-clamp-2 text-xl font-semibold leading-[24px] text-primary hover:underline"
59
+ >
60
+ {featured.name}
61
+ </Link>
62
+ <time dateTime={featured.publishedAt} className="text-sm leading-5 text-primary">
63
+ {formatDate(featured.publishedAt)}
64
+ </time>
65
+ <span className="mt-2 text-sm leading-5 text-primary">{featured.description}</span>
66
+ </div>
67
+
68
+ {/* Sidebar (Right) — next 3 blogs */}
69
+ {rest.length > 0 && (
70
+ <div className="flex w-1/2 flex-col gap-[30px]">
71
+ {rest.slice(0, 3).map((blog) => (
72
+ <Link
73
+ key={blog.slug}
74
+ href={`/blogs/detail/${blog.slug}`}
75
+ className="flex gap-[30px] max-xl:gap-6 max-lg:gap-4"
76
+ >
77
+ <div className="h-[147px] w-[191px] flex-shrink-0 max-lg:h-[130px] max-lg:w-[169px]">
78
+ <Thumbnail src={blog.thumbnail} alt={blog.name} className="rounded-[10px]" />
79
+ </div>
80
+ <div className="flex flex-col gap-2">
81
+ <CategoryBadges categoryIds={blog.categoryIds} categories={categories} />
82
+ <span className="line-clamp-3 text-xl font-semibold leading-[30px] text-primary hover:underline max-xl:text-lg max-lg:text-base">
83
+ {blog.name}
84
+ </span>
85
+ <time dateTime={blog.publishedAt} className="text-sm leading-5 text-primary">
86
+ {formatDate(blog.publishedAt)}
87
+ </time>
88
+ </div>
89
+ </Link>
90
+ ))}
91
+ </div>
92
+ )}
93
+ </div>
94
+
95
+ {/* Grid — blogs beyond index 4 */}
96
+ {data.length > 4 && (
97
+ <div className="flex flex-col gap-[32px]">
98
+ <span className="text-2xl font-semibold leading-8 text-primary">More Blogs</span>
99
+ <div className="grid grid-cols-4 gap-8 max-lg:grid-cols-2">
100
+ {data.slice(4).map((blog) => (
101
+ <Link key={blog.slug} href={`/blogs/detail/${blog.slug}`} className="flex flex-col gap-[30px]">
102
+ <div className="h-[220px] w-full max-lg:h-[160px]">
103
+ <Thumbnail src={blog.thumbnail} alt={blog.name} className="rounded-[16px]" />
104
+ </div>
105
+ <div className="flex flex-col gap-2">
106
+ <CategoryBadges categoryIds={blog.categoryIds} categories={categories} />
107
+ <span className="line-clamp-2 text-xl font-semibold leading-[30px] text-primary max-xl:text-lg max-lg:text-base">
108
+ {blog.name}
109
+ </span>
110
+ <time dateTime={blog.publishedAt} className="text-sm leading-5 text-primary">
111
+ {formatDate(blog.publishedAt)}
112
+ </time>
113
+ </div>
114
+ </Link>
115
+ ))}
116
+ </div>
117
+ </div>
118
+ )}
119
+ </motion.div>
120
+ );
121
+ }
@@ -0,0 +1,90 @@
1
+ "use client";
2
+ import Link from "next/link";
3
+ import { motion } from "motion/react";
4
+ import { Button } from "@/components/ui/button";
5
+ import type { Blog, BlogCategory } from "@/lib/blog-api";
6
+
7
+ function formatDate(date: string) {
8
+ return new Date(date).toLocaleDateString("en-GB");
9
+ }
10
+
11
+ function CategoryBadges({ categoryIds, categories }: { categoryIds?: string[]; categories: BlogCategory[] }) {
12
+ if (!categoryIds?.length) return null;
13
+ return (
14
+ <div className="flex flex-wrap gap-1">
15
+ {categoryIds.map((id) => (
16
+ <Button key={id} className="h-[22px] w-fit rounded-[4px] bg-primary px-2 text-xs text-primary-foreground hover:bg-primary/80">
17
+ {categories.find((c) => c.id === id)?.name ?? id}
18
+ </Button>
19
+ ))}
20
+ </div>
21
+ );
22
+ }
23
+
24
+ export function BlogViewMobile({ data, categories = [] }: { data: Blog[]; categories?: BlogCategory[] }) {
25
+ const [featured, ...rest] = data;
26
+
27
+ return (
28
+ <motion.div
29
+ viewport={{ once: true }}
30
+ initial={{ opacity: 0, y: 40 }}
31
+ whileInView={{ opacity: 1, y: 0 }}
32
+ transition={{ duration: 1.1 }}
33
+ className="flex flex-col gap-6 pb-20"
34
+ >
35
+ {/* Featured Blog */}
36
+ <div className="flex flex-col gap-2">
37
+ <Link href={`/blogs/detail/${featured.slug}`} className="h-[230px] w-full">
38
+ {featured.thumbnail ? (
39
+ <img
40
+ src={featured.thumbnail}
41
+ alt={featured.name}
42
+ loading="eager"
43
+ className="h-full w-full object-cover rounded-[16px]"
44
+ />
45
+ ) : (
46
+ <div className="h-full w-full bg-muted rounded-[16px]" />
47
+ )}
48
+ </Link>
49
+ <CategoryBadges categoryIds={featured.categoryIds} categories={categories} />
50
+ <Link
51
+ href={`/blogs/detail/${featured.slug}`}
52
+ className="text-xl font-semibold leading-[30px] text-primary hover:underline"
53
+ >
54
+ {featured.name}
55
+ </Link>
56
+ <time dateTime={featured.publishedAt} className="text-sm text-primary">
57
+ {formatDate(featured.publishedAt)}
58
+ </time>
59
+ <span className="text-sm text-primary">{featured.description}</span>
60
+ </div>
61
+
62
+ {/* Rest of blogs */}
63
+ <div className="flex flex-col gap-6">
64
+ {rest.map((blog) => (
65
+ <Link key={blog.slug} href={`/blogs/detail/${blog.slug}`} className="flex items-start gap-4">
66
+ <div className="h-[91px] w-[118px] flex-shrink-0">
67
+ {blog.thumbnail ? (
68
+ <img
69
+ src={blog.thumbnail}
70
+ alt={blog.name}
71
+ loading="lazy"
72
+ className="h-full w-full object-cover rounded-[10px]"
73
+ />
74
+ ) : (
75
+ <div className="h-full w-full bg-muted rounded-[10px]" />
76
+ )}
77
+ </div>
78
+ <div className="flex flex-col gap-2">
79
+ <CategoryBadges categoryIds={blog.categoryIds} categories={categories} />
80
+ <span className="text-sm font-semibold text-primary hover:underline">{blog.name}</span>
81
+ <time dateTime={blog.publishedAt} className="text-xs text-primary">
82
+ {formatDate(blog.publishedAt)}
83
+ </time>
84
+ </div>
85
+ </Link>
86
+ ))}
87
+ </div>
88
+ </motion.div>
89
+ );
90
+ }