create-landing-app 0.1.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.
- package/dist/index.js +21 -0
- package/dist/install.js +18 -0
- package/dist/prompts.js +62 -0
- package/dist/scaffold.js +159 -0
- package/dist/utils/__tests__/merge-json.test.js +144 -0
- package/dist/utils/__tests__/replace-tokens.test.js +212 -0
- package/dist/utils/copy-dir.js +22 -0
- package/dist/utils/merge-json.js +19 -0
- package/dist/utils/replace-tokens.js +8 -0
- package/package.json +48 -0
- package/templates/nextjs/base/.env.example +8 -0
- package/templates/nextjs/base/.github/workflows/ci.yml +40 -0
- package/templates/nextjs/base/.husky/commit-msg +7 -0
- package/templates/nextjs/base/.husky/pre-commit +3 -0
- package/templates/nextjs/base/.husky/pre-push +46 -0
- package/templates/nextjs/base/.lighthouserc.json +28 -0
- package/templates/nextjs/base/.prettierignore +11 -0
- package/templates/nextjs/base/.prettierrc.json +10 -0
- package/templates/nextjs/base/Dockerfile +42 -0
- package/templates/nextjs/base/app/globals.css +82 -0
- package/templates/nextjs/base/app/layout.tsx +32 -0
- package/templates/nextjs/base/app/not-found.tsx +13 -0
- package/templates/nextjs/base/app/page.tsx +15 -0
- package/templates/nextjs/base/app/robots.ts +9 -0
- package/templates/nextjs/base/commitlint.config.mjs +32 -0
- package/templates/nextjs/base/components/navs/navbar-mobile.tsx +39 -0
- package/templates/nextjs/base/components/navs/navbar.tsx +39 -0
- package/templates/nextjs/base/components/providers.tsx +12 -0
- package/templates/nextjs/base/components/sections/features-section.tsx +78 -0
- package/templates/nextjs/base/components/sections/footer-section.tsx +98 -0
- package/templates/nextjs/base/components/sections/hero-section.tsx +74 -0
- package/templates/nextjs/base/components/ui/accordion.tsx +47 -0
- package/templates/nextjs/base/components/ui/button.tsx +44 -0
- package/templates/nextjs/base/components/ui/dialog.tsx +61 -0
- package/templates/nextjs/base/components/ui/dropdown-menu.tsx +55 -0
- package/templates/nextjs/base/components/ui/sonner.tsx +6 -0
- package/templates/nextjs/base/components.json +19 -0
- package/templates/nextjs/base/constants/common.ts +15 -0
- package/templates/nextjs/base/eslint.config.mjs +25 -0
- package/templates/nextjs/base/lib/metadata.ts +36 -0
- package/templates/nextjs/base/lib/utils.ts +7 -0
- package/templates/nextjs/base/next.config.ts +33 -0
- package/templates/nextjs/base/package.json +61 -0
- package/templates/nextjs/base/postcss.config.mjs +7 -0
- package/templates/nextjs/base/scripts/build-and-scan.sh +127 -0
- package/templates/nextjs/base/scripts/lighthouse-check.sh +86 -0
- package/templates/nextjs/base/styles/theme.css +63 -0
- package/templates/nextjs/base/tsconfig.json +21 -0
- package/templates/nextjs/base/types/index.ts +16 -0
- package/templates/nextjs/optional/docker/files/.dockerignore +6 -0
- package/templates/nextjs/optional/docker/files/Dockerfile +36 -0
- package/templates/nextjs/optional/docker/files/docker-compose.yml +9 -0
- package/templates/nextjs/optional/i18n-dict/files/app/[lang]/layout.tsx +19 -0
- package/templates/nextjs/optional/i18n-dict/files/app/[lang]/page.tsx +15 -0
- package/templates/nextjs/optional/i18n-dict/files/components/navs/language-switcher.tsx +39 -0
- package/templates/nextjs/optional/i18n-dict/files/components/navs/navbar-mobile.tsx +41 -0
- package/templates/nextjs/optional/i18n-dict/files/components/navs/navbar.tsx +41 -0
- package/templates/nextjs/optional/i18n-dict/files/components/providers.tsx +16 -0
- package/templates/nextjs/optional/i18n-dict/files/components/sections/features-section.tsx +80 -0
- package/templates/nextjs/optional/i18n-dict/files/components/sections/footer-section.tsx +98 -0
- package/templates/nextjs/optional/i18n-dict/files/dictionaries/en.json +21 -0
- package/templates/nextjs/optional/i18n-dict/files/dictionaries/vi.json +21 -0
- package/templates/nextjs/optional/i18n-dict/files/get-dictionary.ts +10 -0
- package/templates/nextjs/optional/i18n-dict/files/i18n-config.ts +6 -0
- package/templates/nextjs/optional/i18n-dict/files/lib/dict-context.tsx +23 -0
- package/templates/nextjs/optional/i18n-dict/files/middleware.ts +31 -0
- package/templates/nextjs/optional/i18n-dict/pkg.json +9 -0
- package/templates/nextjs/optional/sections/about/files/components/sections/about-section.tsx +36 -0
- package/templates/nextjs/optional/sections/about/inject/app__[lang]__page.tsx +5 -0
- package/templates/nextjs/optional/sections/about/inject/app__page.tsx +5 -0
- package/templates/nextjs/optional/sections/about/inject/constants__common.ts +2 -0
- package/templates/nextjs/optional/sections/blog/files/components/sections/blog-section.tsx +191 -0
- package/templates/nextjs/optional/sections/blog/inject/app__[lang]__page.tsx +5 -0
- package/templates/nextjs/optional/sections/blog/inject/app__page.tsx +5 -0
- package/templates/nextjs/optional/sections/blog/inject/constants__common.ts +2 -0
- package/templates/nextjs/optional/sections/contact/files/components/sections/contact-section.tsx +79 -0
- package/templates/nextjs/optional/sections/contact/inject/app__[lang]__page.tsx +5 -0
- package/templates/nextjs/optional/sections/contact/inject/app__page.tsx +5 -0
- package/templates/nextjs/optional/sections/contact/inject/constants__common.ts +2 -0
- package/templates/nextjs/optional/tanstack-query/files/lib/custom-fetch.ts +9 -0
- package/templates/nextjs/optional/tanstack-query/files/lib/query-client.ts +21 -0
- package/templates/nextjs/optional/tanstack-query/inject/components__providers.tsx +9 -0
- package/templates/nextjs/optional/tanstack-query/pkg.json +5 -0
- package/templates/nextjs/optional/zustand/files/store/ui-store.ts +16 -0
- package/templates/nextjs/optional/zustand/inject/components__providers.tsx +3 -0
- package/templates/nextjs/optional/zustand/pkg.json +5 -0
- package/templates/nextjs/themes/dark.css +36 -0
- package/templates/nextjs/themes/forest.css +58 -0
- package/templates/nextjs/themes/ocean.css +58 -0
- package/templates/nextjs/themes/pila.css +75 -0
- package/templates/nextjs/themes/purple.css +58 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import Link from "next/link";
|
|
3
|
+
import { SITE_NAME, NAV_LINKS, SOCIAL_LINKS } from "@/constants/common";
|
|
4
|
+
import { Github, Twitter, Linkedin } from "lucide-react";
|
|
5
|
+
import { useDictionary } from "@/lib/dict-context";
|
|
6
|
+
|
|
7
|
+
export default function FooterSection() {
|
|
8
|
+
const dict = useDictionary();
|
|
9
|
+
return (
|
|
10
|
+
<footer className="bg-footer text-white">
|
|
11
|
+
<div className="content-container py-16">
|
|
12
|
+
<div className="mb-12 grid gap-12 md:grid-cols-4">
|
|
13
|
+
{/* Brand */}
|
|
14
|
+
<div className="space-y-4 md:col-span-2">
|
|
15
|
+
<p className="text-2xl font-bold">{SITE_NAME}</p>
|
|
16
|
+
<p className="max-w-xs text-sm leading-relaxed text-white/70">
|
|
17
|
+
A modern landing page starter kit. Built for developers who ship fast.
|
|
18
|
+
</p>
|
|
19
|
+
{/* Social links */}
|
|
20
|
+
<div className="flex items-center gap-4">
|
|
21
|
+
{SOCIAL_LINKS.github && (
|
|
22
|
+
<Link
|
|
23
|
+
href={SOCIAL_LINKS.github}
|
|
24
|
+
className="text-white/70 transition-colors hover:text-white"
|
|
25
|
+
>
|
|
26
|
+
<Github className="h-5 w-5" />
|
|
27
|
+
</Link>
|
|
28
|
+
)}
|
|
29
|
+
{SOCIAL_LINKS.twitter && (
|
|
30
|
+
<Link
|
|
31
|
+
href={SOCIAL_LINKS.twitter}
|
|
32
|
+
className="text-white/70 transition-colors hover:text-white"
|
|
33
|
+
>
|
|
34
|
+
<Twitter className="h-5 w-5" />
|
|
35
|
+
</Link>
|
|
36
|
+
)}
|
|
37
|
+
{SOCIAL_LINKS.linkedin && (
|
|
38
|
+
<Link
|
|
39
|
+
href={SOCIAL_LINKS.linkedin}
|
|
40
|
+
className="text-white/70 transition-colors hover:text-white"
|
|
41
|
+
>
|
|
42
|
+
<Linkedin className="h-5 w-5" />
|
|
43
|
+
</Link>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
{/* Nav links */}
|
|
49
|
+
<div className="space-y-4">
|
|
50
|
+
<p className="text-sm font-semibold uppercase tracking-wider text-white/50">
|
|
51
|
+
Navigation
|
|
52
|
+
</p>
|
|
53
|
+
<ul className="space-y-3">
|
|
54
|
+
{NAV_LINKS.map((link) => (
|
|
55
|
+
<li key={link.href}>
|
|
56
|
+
<Link
|
|
57
|
+
href={link.href}
|
|
58
|
+
className="text-sm text-white/70 transition-colors hover:text-white"
|
|
59
|
+
>
|
|
60
|
+
{link.label}
|
|
61
|
+
</Link>
|
|
62
|
+
</li>
|
|
63
|
+
))}
|
|
64
|
+
</ul>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
{/* Legal */}
|
|
68
|
+
<div className="space-y-4">
|
|
69
|
+
<p className="text-sm font-semibold uppercase tracking-wider text-white/50">Legal</p>
|
|
70
|
+
<ul className="space-y-3">
|
|
71
|
+
<li>
|
|
72
|
+
<Link
|
|
73
|
+
href="/privacy"
|
|
74
|
+
className="text-sm text-white/70 transition-colors hover:text-white"
|
|
75
|
+
>
|
|
76
|
+
Privacy Policy
|
|
77
|
+
</Link>
|
|
78
|
+
</li>
|
|
79
|
+
<li>
|
|
80
|
+
<Link
|
|
81
|
+
href="/terms"
|
|
82
|
+
className="text-sm text-white/70 transition-colors hover:text-white"
|
|
83
|
+
>
|
|
84
|
+
Terms of Service
|
|
85
|
+
</Link>
|
|
86
|
+
</li>
|
|
87
|
+
</ul>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{/* Bottom bar */}
|
|
92
|
+
<div className="border-t border-white/10 pt-8">
|
|
93
|
+
<p className="text-center text-sm text-white/50">{dict.footer.copyright}</p>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</footer>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"nav": {
|
|
3
|
+
"features": "Features",
|
|
4
|
+
"about": "About",
|
|
5
|
+
"contact": "Contact",
|
|
6
|
+
"getStarted": "Get started"
|
|
7
|
+
},
|
|
8
|
+
"hero": {
|
|
9
|
+
"headline": "Build faster, ship better",
|
|
10
|
+
"subtext": "The modern landing page starter kit for your next project.",
|
|
11
|
+
"cta": "Get started",
|
|
12
|
+
"ctaSecondary": "Learn more"
|
|
13
|
+
},
|
|
14
|
+
"features": {
|
|
15
|
+
"title": "Everything you need",
|
|
16
|
+
"subtitle": "Built with the best tools in the ecosystem"
|
|
17
|
+
},
|
|
18
|
+
"footer": {
|
|
19
|
+
"copyright": "© 2025 __PROJECT_NAME__. All rights reserved."
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"nav": {
|
|
3
|
+
"features": "Tính năng",
|
|
4
|
+
"about": "Về chúng tôi",
|
|
5
|
+
"contact": "Liên hệ",
|
|
6
|
+
"getStarted": "Bắt đầu"
|
|
7
|
+
},
|
|
8
|
+
"hero": {
|
|
9
|
+
"headline": "Xây dựng nhanh hơn, ra mắt tốt hơn",
|
|
10
|
+
"subtext": "Bộ khởi đầu trang đích hiện đại cho dự án tiếp theo của bạn.",
|
|
11
|
+
"cta": "Bắt đầu",
|
|
12
|
+
"ctaSecondary": "Tìm hiểu thêm"
|
|
13
|
+
},
|
|
14
|
+
"features": {
|
|
15
|
+
"title": "Mọi thứ bạn cần",
|
|
16
|
+
"subtitle": "Được xây dựng với các công cụ tốt nhất"
|
|
17
|
+
},
|
|
18
|
+
"footer": {
|
|
19
|
+
"copyright": "© 2025 __PROJECT_NAME__. Bảo lưu mọi quyền."
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Locale } from "@/i18n-config";
|
|
2
|
+
|
|
3
|
+
// Lazy-load dictionary to avoid bundling all locales
|
|
4
|
+
const dictionaries = {
|
|
5
|
+
en: () => import("@/dictionaries/en.json").then((m) => m.default),
|
|
6
|
+
vi: () => import("@/dictionaries/vi.json").then((m) => m.default),
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const getDictionary = async (locale: Locale) =>
|
|
10
|
+
dictionaries[locale]?.() ?? dictionaries.en();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createContext, useContext } from "react";
|
|
3
|
+
import defaultDict from "@/dictionaries/en.json";
|
|
4
|
+
import type en from "@/dictionaries/en.json";
|
|
5
|
+
|
|
6
|
+
type Dictionary = typeof en;
|
|
7
|
+
|
|
8
|
+
const DictContext = createContext<Dictionary | null>(null);
|
|
9
|
+
|
|
10
|
+
export function DictProvider({
|
|
11
|
+
dict,
|
|
12
|
+
children,
|
|
13
|
+
}: {
|
|
14
|
+
dict: Dictionary;
|
|
15
|
+
children: React.ReactNode;
|
|
16
|
+
}) {
|
|
17
|
+
return <DictContext.Provider value={dict}>{children}</DictContext.Provider>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Falls back to English if no provider in tree
|
|
21
|
+
export function useDictionary(): Dictionary {
|
|
22
|
+
return useContext(DictContext) ?? defaultDict;
|
|
23
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import type { NextRequest } from "next/server";
|
|
3
|
+
import { match as matchLocale } from "@formatjs/intl-localematcher";
|
|
4
|
+
import Negotiator from "negotiator";
|
|
5
|
+
import { i18n } from "@/i18n-config";
|
|
6
|
+
|
|
7
|
+
function getLocale(request: NextRequest): string {
|
|
8
|
+
const negotiatorHeaders: Record<string, string> = {};
|
|
9
|
+
request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));
|
|
10
|
+
// Filter out wildcard "*" — Intl.getCanonicalLocales throws on it
|
|
11
|
+
const languages = new Negotiator({ headers: negotiatorHeaders })
|
|
12
|
+
.languages()
|
|
13
|
+
.filter((lang) => lang !== "*");
|
|
14
|
+
return matchLocale(languages, [...i18n.locales], i18n.defaultLocale);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function middleware(request: NextRequest) {
|
|
18
|
+
const { pathname } = request.nextUrl;
|
|
19
|
+
const pathnameHasLocale = i18n.locales.some(
|
|
20
|
+
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
|
|
21
|
+
);
|
|
22
|
+
if (pathnameHasLocale) return;
|
|
23
|
+
|
|
24
|
+
const locale = getLocale(request);
|
|
25
|
+
request.nextUrl.pathname = `/${locale}${pathname}`;
|
|
26
|
+
return NextResponse.redirect(request.nextUrl);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const config = {
|
|
30
|
+
matcher: ["/((?!_next|api|favicon.ico|.*\\..*).*)"],
|
|
31
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { motion } from "motion/react";
|
|
3
|
+
|
|
4
|
+
// Replace placeholder content with your actual about content
|
|
5
|
+
export default function AboutSection() {
|
|
6
|
+
return (
|
|
7
|
+
<section id="about" className="py-24">
|
|
8
|
+
<div className="content-container">
|
|
9
|
+
<motion.div
|
|
10
|
+
initial={{ opacity: 0, y: 30 }}
|
|
11
|
+
whileInView={{ opacity: 1, y: 0 }}
|
|
12
|
+
viewport={{ once: true }}
|
|
13
|
+
transition={{ duration: 0.6 }}
|
|
14
|
+
className="grid items-center gap-12 md:grid-cols-2"
|
|
15
|
+
>
|
|
16
|
+
{/* Text */}
|
|
17
|
+
<div className="space-y-6">
|
|
18
|
+
<h2 className="text-4xl font-bold">About us</h2>
|
|
19
|
+
<p className="text-lg leading-relaxed text-muted-foreground">
|
|
20
|
+
We build products that make a difference. Our team is passionate about creating
|
|
21
|
+
exceptional experiences that delight users and drive results.
|
|
22
|
+
</p>
|
|
23
|
+
<p className="leading-relaxed text-muted-foreground">
|
|
24
|
+
Founded in 2024, we have helped hundreds of companies launch successful products with
|
|
25
|
+
our modern approach to design and development.
|
|
26
|
+
</p>
|
|
27
|
+
</div>
|
|
28
|
+
{/* Visual placeholder */}
|
|
29
|
+
<div className="flex h-80 items-center justify-center rounded-2xl bg-accent">
|
|
30
|
+
<span className="text-muted-foreground">Your image here</span>
|
|
31
|
+
</div>
|
|
32
|
+
</motion.div>
|
|
33
|
+
</div>
|
|
34
|
+
</section>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import Link from "next/link";
|
|
3
|
+
import { motion } from "motion/react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
|
|
6
|
+
type BlogPost = {
|
|
7
|
+
slug: string;
|
|
8
|
+
title: string;
|
|
9
|
+
description: string;
|
|
10
|
+
date: string;
|
|
11
|
+
category: string;
|
|
12
|
+
thumbnail?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Replace with your CMS/API data
|
|
16
|
+
const BLOG_POSTS: BlogPost[] = [
|
|
17
|
+
{ slug: "post-1", title: "Getting started with our platform", description: "Learn how to set up your account and launch your first project in minutes.", date: "2024-01-15", category: "Product" },
|
|
18
|
+
{ slug: "post-2", title: "Best practices for modern web apps", description: "A guide to building scalable, maintainable applications.", date: "2024-02-01", category: "Engineering" },
|
|
19
|
+
{ slug: "post-3", title: "How we scaled to 10k users", description: "A behind-the-scenes look at the challenges and solutions.", date: "2024-02-20", category: "Growth" },
|
|
20
|
+
{ slug: "post-4", title: "Designing for accessibility", description: "Why inclusive design benefits everyone and how to do it.", date: "2024-03-05", category: "Design" },
|
|
21
|
+
{ slug: "post-5", title: "Our open-source journey", description: "What we learned shipping our first open-source library.", date: "2024-03-18", category: "Engineering" },
|
|
22
|
+
{ slug: "post-6", title: "The future of edge computing", description: "How edge functions are changing how we think about latency.", date: "2024-04-01", category: "Product" },
|
|
23
|
+
{ slug: "post-7", title: "Team rituals that actually work", description: "Async-first habits that helped our remote team stay aligned.", date: "2024-04-10", category: "Culture" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function formatDate(date: string) {
|
|
27
|
+
return new Date(date).toLocaleDateString("en-GB"); // DD/MM/YYYY
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function CategoryBadge({ label }: { label: string }) {
|
|
31
|
+
return (
|
|
32
|
+
<Button className="h-[22px] w-fit rounded-[4px] bg-primary text-xs leading-[18px] tracking-[-0.24px] text-primary-foreground hover:bg-primary/80">
|
|
33
|
+
{label}
|
|
34
|
+
</Button>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function Thumbnail({ src, alt, className }: { src?: string; alt?: string; className: string }) {
|
|
39
|
+
return src ? (
|
|
40
|
+
<img src={src} className={`h-full w-full object-cover ${className}`} alt={alt ?? ""} loading="lazy" />
|
|
41
|
+
) : (
|
|
42
|
+
<div className={`h-full w-full bg-muted ${className}`} />
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Desktop: featured left + 3 secondary right + optional related grid
|
|
47
|
+
function BlogViewDesktop({ posts }: { posts: BlogPost[] }) {
|
|
48
|
+
const [featured, ...rest] = posts;
|
|
49
|
+
return (
|
|
50
|
+
<motion.div
|
|
51
|
+
viewport={{ once: true }}
|
|
52
|
+
initial={{ opacity: 0, y: 40 }}
|
|
53
|
+
whileInView={{ opacity: 1, y: 0 }}
|
|
54
|
+
transition={{ duration: 1.1 }}
|
|
55
|
+
className="flex flex-col gap-[128px] pb-20"
|
|
56
|
+
>
|
|
57
|
+
<div className="flex justify-between gap-[29px] max-lg:gap-5">
|
|
58
|
+
{/* Featured post */}
|
|
59
|
+
<div className={`flex flex-col gap-2 ${rest.length === 0 ? "w-full" : "w-1/2"}`}>
|
|
60
|
+
<Link href={`/blog/${featured.slug}`} className={`w-full ${rest.length === 0 ? "h-[500px]" : "h-[320px]"}`}>
|
|
61
|
+
<Thumbnail src={featured.thumbnail} className="rounded-[16px]" />
|
|
62
|
+
</Link>
|
|
63
|
+
<div className="mt-1">
|
|
64
|
+
<CategoryBadge label={featured.category} />
|
|
65
|
+
</div>
|
|
66
|
+
<Link href={`/blog/${featured.slug}`} className="line-clamp-2 text-xl font-semibold leading-[24px] text-primary hover:underline">
|
|
67
|
+
{featured.title}
|
|
68
|
+
</Link>
|
|
69
|
+
<time dateTime={featured.date} className="text-sm leading-5 text-primary">
|
|
70
|
+
{formatDate(featured.date)}
|
|
71
|
+
</time>
|
|
72
|
+
<span className="mt-2 text-sm leading-5 text-primary">{featured.description}</span>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
{/* Secondary posts */}
|
|
76
|
+
{rest.length > 0 && (
|
|
77
|
+
<div className="flex w-1/2 flex-col gap-[30px]">
|
|
78
|
+
{rest.slice(0, 3).map((post) => (
|
|
79
|
+
<Link key={post.slug} href={`/blog/${post.slug}`} className="flex gap-[30px] max-xl:gap-6 max-lg:gap-4">
|
|
80
|
+
<div className="h-[147px] w-[191px] flex-shrink-0 max-lg:h-[130px] max-lg:w-[169px]">
|
|
81
|
+
<Thumbnail src={post.thumbnail} className="rounded-[10px]" />
|
|
82
|
+
</div>
|
|
83
|
+
<div className="flex flex-col gap-2">
|
|
84
|
+
<CategoryBadge label={post.category} />
|
|
85
|
+
<span className="text-xl font-semibold leading-[30px] text-primary hover:underline max-xl:text-lg max-lg:text-base">
|
|
86
|
+
{post.title}
|
|
87
|
+
</span>
|
|
88
|
+
<time dateTime={post.date} className="text-sm leading-5 text-primary">
|
|
89
|
+
{formatDate(post.date)}
|
|
90
|
+
</time>
|
|
91
|
+
</div>
|
|
92
|
+
</Link>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
{/* Related: 4-col grid for posts beyond index 4 */}
|
|
99
|
+
{posts.length > 4 && (
|
|
100
|
+
<div className="flex flex-col gap-[32px]">
|
|
101
|
+
<span className="text-2xl font-semibold leading-8 text-primary">Related posts</span>
|
|
102
|
+
<div className="grid grid-cols-4 gap-8">
|
|
103
|
+
{posts.slice(4).map((post) => (
|
|
104
|
+
<Link key={post.slug} href={`/blog/${post.slug}`} className="flex flex-col gap-[30px]">
|
|
105
|
+
<div className="h-[220px] w-full max-lg:h-[130px]">
|
|
106
|
+
<Thumbnail src={post.thumbnail} alt={post.title} className="rounded-[16px]" />
|
|
107
|
+
</div>
|
|
108
|
+
<div className="flex flex-col gap-2">
|
|
109
|
+
<CategoryBadge label={post.category} />
|
|
110
|
+
<span className="text-xl font-semibold leading-[30px] text-primary max-xl:text-lg max-lg:text-base">
|
|
111
|
+
{post.title}
|
|
112
|
+
</span>
|
|
113
|
+
<time dateTime={post.date} className="text-sm leading-5 text-primary">
|
|
114
|
+
{formatDate(post.date)}
|
|
115
|
+
</time>
|
|
116
|
+
</div>
|
|
117
|
+
</Link>
|
|
118
|
+
))}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
</motion.div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Mobile: full-width featured + horizontal list of remaining
|
|
127
|
+
function BlogViewMobile({ posts }: { posts: BlogPost[] }) {
|
|
128
|
+
const [featured, ...rest] = posts;
|
|
129
|
+
return (
|
|
130
|
+
<motion.div
|
|
131
|
+
viewport={{ once: true }}
|
|
132
|
+
initial={{ opacity: 0, y: 40 }}
|
|
133
|
+
whileInView={{ opacity: 1, y: 0 }}
|
|
134
|
+
transition={{ duration: 1.1 }}
|
|
135
|
+
className="flex flex-col gap-6 pb-20"
|
|
136
|
+
>
|
|
137
|
+
<div className="flex flex-col gap-2">
|
|
138
|
+
<Link href={`/blog/${featured.slug}`} className="h-[230px] w-full">
|
|
139
|
+
<Thumbnail src={featured.thumbnail} className="rounded-[16px]" />
|
|
140
|
+
</Link>
|
|
141
|
+
<div className="mt-1">
|
|
142
|
+
<CategoryBadge label={featured.category} />
|
|
143
|
+
</div>
|
|
144
|
+
<Link href={`/blog/${featured.slug}`} className="text-xl font-semibold leading-[30px] text-primary hover:underline max-lg:text-lg">
|
|
145
|
+
{featured.title}
|
|
146
|
+
</Link>
|
|
147
|
+
<time dateTime={featured.date} className="text-sm leading-5 text-primary">
|
|
148
|
+
{formatDate(featured.date)}
|
|
149
|
+
</time>
|
|
150
|
+
<span className="mt-2 text-sm leading-5 text-primary">{featured.description}</span>
|
|
151
|
+
</div>
|
|
152
|
+
<div className="flex flex-col gap-6">
|
|
153
|
+
{rest.map((post) => (
|
|
154
|
+
<Link key={post.slug} href={`/blog/${post.slug}`} className="flex items-start gap-4">
|
|
155
|
+
<div className="h-[91px] w-[118px] flex-shrink-0">
|
|
156
|
+
<Thumbnail src={post.thumbnail} className="rounded-[10px]" />
|
|
157
|
+
</div>
|
|
158
|
+
<div className="flex flex-col gap-2">
|
|
159
|
+
<CategoryBadge label={post.category} />
|
|
160
|
+
<span className="text-sm font-semibold leading-6 text-primary">{post.title}</span>
|
|
161
|
+
<time dateTime={post.date} className="text-sm leading-5 text-primary">
|
|
162
|
+
{formatDate(post.date)}
|
|
163
|
+
</time>
|
|
164
|
+
</div>
|
|
165
|
+
</Link>
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
</motion.div>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export default function BlogSection() {
|
|
173
|
+
return (
|
|
174
|
+
<section id="blog" className="py-24">
|
|
175
|
+
<div className="content-container">
|
|
176
|
+
<div className="mb-12 space-y-4 text-center">
|
|
177
|
+
<h2 className="text-4xl font-bold text-primary">From the blog</h2>
|
|
178
|
+
<p className="mx-auto max-w-2xl text-lg text-muted-foreground">
|
|
179
|
+
Insights, tutorials, and updates from our team.
|
|
180
|
+
</p>
|
|
181
|
+
</div>
|
|
182
|
+
<div className="hidden md:block">
|
|
183
|
+
<BlogViewDesktop posts={BLOG_POSTS} />
|
|
184
|
+
</div>
|
|
185
|
+
<div className="block md:hidden">
|
|
186
|
+
<BlogViewMobile posts={BLOG_POSTS} />
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
</section>
|
|
190
|
+
);
|
|
191
|
+
}
|
package/templates/nextjs/optional/sections/contact/files/components/sections/contact-section.tsx
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { motion } from "motion/react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
|
|
7
|
+
interface FormData {
|
|
8
|
+
name: string;
|
|
9
|
+
email: string;
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function ContactSection() {
|
|
14
|
+
const [form, setForm] = useState<FormData>({ name: "", email: "", message: "" });
|
|
15
|
+
const [submitting, setSubmitting] = useState(false);
|
|
16
|
+
|
|
17
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
18
|
+
e.preventDefault();
|
|
19
|
+
setSubmitting(true);
|
|
20
|
+
try {
|
|
21
|
+
// Replace with your actual form submission logic
|
|
22
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
23
|
+
toast.success("Message sent! We'll get back to you soon.");
|
|
24
|
+
setForm({ name: "", email: "", message: "" });
|
|
25
|
+
} catch {
|
|
26
|
+
toast.error("Failed to send. Please try again.");
|
|
27
|
+
} finally {
|
|
28
|
+
setSubmitting(false);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<section id="contact" className="bg-secondary/30 py-24">
|
|
34
|
+
<div className="content-container max-w-2xl">
|
|
35
|
+
<motion.div
|
|
36
|
+
initial={{ opacity: 0, y: 30 }}
|
|
37
|
+
whileInView={{ opacity: 1, y: 0 }}
|
|
38
|
+
viewport={{ once: true }}
|
|
39
|
+
transition={{ duration: 0.6 }}
|
|
40
|
+
className="space-y-8"
|
|
41
|
+
>
|
|
42
|
+
<div className="space-y-4 text-center">
|
|
43
|
+
<h2 className="text-4xl font-bold">Get in touch</h2>
|
|
44
|
+
<p className="text-lg text-muted-foreground">We'd love to hear from you.</p>
|
|
45
|
+
</div>
|
|
46
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
47
|
+
<input
|
|
48
|
+
type="text"
|
|
49
|
+
placeholder="Your name"
|
|
50
|
+
required
|
|
51
|
+
value={form.name}
|
|
52
|
+
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
53
|
+
className="w-full rounded-lg border border-border bg-background px-4 py-3 focus:outline-none focus:ring-2 focus:ring-ring"
|
|
54
|
+
/>
|
|
55
|
+
<input
|
|
56
|
+
type="email"
|
|
57
|
+
placeholder="Email address"
|
|
58
|
+
required
|
|
59
|
+
value={form.email}
|
|
60
|
+
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
|
61
|
+
className="w-full rounded-lg border border-border bg-background px-4 py-3 focus:outline-none focus:ring-2 focus:ring-ring"
|
|
62
|
+
/>
|
|
63
|
+
<textarea
|
|
64
|
+
placeholder="Your message"
|
|
65
|
+
required
|
|
66
|
+
rows={5}
|
|
67
|
+
value={form.message}
|
|
68
|
+
onChange={(e) => setForm({ ...form, message: e.target.value })}
|
|
69
|
+
className="w-full resize-none rounded-lg border border-border bg-background px-4 py-3 focus:outline-none focus:ring-2 focus:ring-ring"
|
|
70
|
+
/>
|
|
71
|
+
<Button type="submit" className="w-full" disabled={submitting}>
|
|
72
|
+
{submitting ? "Sending..." : "Send message"}
|
|
73
|
+
</Button>
|
|
74
|
+
</form>
|
|
75
|
+
</motion.div>
|
|
76
|
+
</div>
|
|
77
|
+
</section>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Typed fetch wrapper — use in TanStack Query queryFn
|
|
2
|
+
export async function customFetch<T>(url: string, options?: RequestInit): Promise<T> {
|
|
3
|
+
const res = await fetch(url, {
|
|
4
|
+
headers: { "Content-Type": "application/json", ...options?.headers },
|
|
5
|
+
...options,
|
|
6
|
+
});
|
|
7
|
+
if (!res.ok) throw new Error(`Fetch error: ${res.status} ${res.statusText}`);
|
|
8
|
+
return res.json() as Promise<T>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { QueryClient } from "@tanstack/react-query";
|
|
2
|
+
|
|
3
|
+
// Singleton QueryClient for server-side usage
|
|
4
|
+
export function makeQueryClient() {
|
|
5
|
+
return new QueryClient({
|
|
6
|
+
defaultOptions: {
|
|
7
|
+
queries: {
|
|
8
|
+
staleTime: 60 * 1000, // 1 minute
|
|
9
|
+
retry: 1,
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let browserQueryClient: QueryClient | undefined;
|
|
16
|
+
|
|
17
|
+
export function getQueryClient() {
|
|
18
|
+
if (typeof window === "undefined") return makeQueryClient();
|
|
19
|
+
if (!browserQueryClient) browserQueryClient = makeQueryClient();
|
|
20
|
+
return browserQueryClient;
|
|
21
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MARKER:__PROVIDERS_IMPORT__
|
|
2
|
+
import { QueryClientProvider } from "@tanstack/react-query";
|
|
3
|
+
import { getQueryClient } from "@/lib/query-client";
|
|
4
|
+
---
|
|
5
|
+
MARKER:__PROVIDERS_WRAP_START__
|
|
6
|
+
<QueryClientProvider client={getQueryClient()}>
|
|
7
|
+
---
|
|
8
|
+
MARKER:__PROVIDERS_WRAP_END__
|
|
9
|
+
</QueryClientProvider>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
|
|
3
|
+
// Global UI state — mobile menu, modals, etc.
|
|
4
|
+
// Only use this for state shared across 3+ components.
|
|
5
|
+
// Local state (useState) is preferred for single-component state.
|
|
6
|
+
interface UIStore {
|
|
7
|
+
mobileMenuOpen: boolean;
|
|
8
|
+
setMobileMenuOpen: (open: boolean) => void;
|
|
9
|
+
toggleMobileMenu: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const useUIStore = create<UIStore>((set) => ({
|
|
13
|
+
mobileMenuOpen: false,
|
|
14
|
+
setMobileMenuOpen: (open) => set({ mobileMenuOpen: open }),
|
|
15
|
+
toggleMobileMenu: () => set((s) => ({ mobileMenuOpen: !s.mobileMenuOpen })),
|
|
16
|
+
}));
|