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.
Files changed (91) hide show
  1. package/dist/index.js +21 -0
  2. package/dist/install.js +18 -0
  3. package/dist/prompts.js +62 -0
  4. package/dist/scaffold.js +159 -0
  5. package/dist/utils/__tests__/merge-json.test.js +144 -0
  6. package/dist/utils/__tests__/replace-tokens.test.js +212 -0
  7. package/dist/utils/copy-dir.js +22 -0
  8. package/dist/utils/merge-json.js +19 -0
  9. package/dist/utils/replace-tokens.js +8 -0
  10. package/package.json +48 -0
  11. package/templates/nextjs/base/.env.example +8 -0
  12. package/templates/nextjs/base/.github/workflows/ci.yml +40 -0
  13. package/templates/nextjs/base/.husky/commit-msg +7 -0
  14. package/templates/nextjs/base/.husky/pre-commit +3 -0
  15. package/templates/nextjs/base/.husky/pre-push +46 -0
  16. package/templates/nextjs/base/.lighthouserc.json +28 -0
  17. package/templates/nextjs/base/.prettierignore +11 -0
  18. package/templates/nextjs/base/.prettierrc.json +10 -0
  19. package/templates/nextjs/base/Dockerfile +42 -0
  20. package/templates/nextjs/base/app/globals.css +82 -0
  21. package/templates/nextjs/base/app/layout.tsx +32 -0
  22. package/templates/nextjs/base/app/not-found.tsx +13 -0
  23. package/templates/nextjs/base/app/page.tsx +15 -0
  24. package/templates/nextjs/base/app/robots.ts +9 -0
  25. package/templates/nextjs/base/commitlint.config.mjs +32 -0
  26. package/templates/nextjs/base/components/navs/navbar-mobile.tsx +39 -0
  27. package/templates/nextjs/base/components/navs/navbar.tsx +39 -0
  28. package/templates/nextjs/base/components/providers.tsx +12 -0
  29. package/templates/nextjs/base/components/sections/features-section.tsx +78 -0
  30. package/templates/nextjs/base/components/sections/footer-section.tsx +98 -0
  31. package/templates/nextjs/base/components/sections/hero-section.tsx +74 -0
  32. package/templates/nextjs/base/components/ui/accordion.tsx +47 -0
  33. package/templates/nextjs/base/components/ui/button.tsx +44 -0
  34. package/templates/nextjs/base/components/ui/dialog.tsx +61 -0
  35. package/templates/nextjs/base/components/ui/dropdown-menu.tsx +55 -0
  36. package/templates/nextjs/base/components/ui/sonner.tsx +6 -0
  37. package/templates/nextjs/base/components.json +19 -0
  38. package/templates/nextjs/base/constants/common.ts +15 -0
  39. package/templates/nextjs/base/eslint.config.mjs +25 -0
  40. package/templates/nextjs/base/lib/metadata.ts +36 -0
  41. package/templates/nextjs/base/lib/utils.ts +7 -0
  42. package/templates/nextjs/base/next.config.ts +33 -0
  43. package/templates/nextjs/base/package.json +61 -0
  44. package/templates/nextjs/base/postcss.config.mjs +7 -0
  45. package/templates/nextjs/base/scripts/build-and-scan.sh +127 -0
  46. package/templates/nextjs/base/scripts/lighthouse-check.sh +86 -0
  47. package/templates/nextjs/base/styles/theme.css +63 -0
  48. package/templates/nextjs/base/tsconfig.json +21 -0
  49. package/templates/nextjs/base/types/index.ts +16 -0
  50. package/templates/nextjs/optional/docker/files/.dockerignore +6 -0
  51. package/templates/nextjs/optional/docker/files/Dockerfile +36 -0
  52. package/templates/nextjs/optional/docker/files/docker-compose.yml +9 -0
  53. package/templates/nextjs/optional/i18n-dict/files/app/[lang]/layout.tsx +19 -0
  54. package/templates/nextjs/optional/i18n-dict/files/app/[lang]/page.tsx +15 -0
  55. package/templates/nextjs/optional/i18n-dict/files/components/navs/language-switcher.tsx +39 -0
  56. package/templates/nextjs/optional/i18n-dict/files/components/navs/navbar-mobile.tsx +41 -0
  57. package/templates/nextjs/optional/i18n-dict/files/components/navs/navbar.tsx +41 -0
  58. package/templates/nextjs/optional/i18n-dict/files/components/providers.tsx +16 -0
  59. package/templates/nextjs/optional/i18n-dict/files/components/sections/features-section.tsx +80 -0
  60. package/templates/nextjs/optional/i18n-dict/files/components/sections/footer-section.tsx +98 -0
  61. package/templates/nextjs/optional/i18n-dict/files/dictionaries/en.json +21 -0
  62. package/templates/nextjs/optional/i18n-dict/files/dictionaries/vi.json +21 -0
  63. package/templates/nextjs/optional/i18n-dict/files/get-dictionary.ts +10 -0
  64. package/templates/nextjs/optional/i18n-dict/files/i18n-config.ts +6 -0
  65. package/templates/nextjs/optional/i18n-dict/files/lib/dict-context.tsx +23 -0
  66. package/templates/nextjs/optional/i18n-dict/files/middleware.ts +31 -0
  67. package/templates/nextjs/optional/i18n-dict/pkg.json +9 -0
  68. package/templates/nextjs/optional/sections/about/files/components/sections/about-section.tsx +36 -0
  69. package/templates/nextjs/optional/sections/about/inject/app__[lang]__page.tsx +5 -0
  70. package/templates/nextjs/optional/sections/about/inject/app__page.tsx +5 -0
  71. package/templates/nextjs/optional/sections/about/inject/constants__common.ts +2 -0
  72. package/templates/nextjs/optional/sections/blog/files/components/sections/blog-section.tsx +191 -0
  73. package/templates/nextjs/optional/sections/blog/inject/app__[lang]__page.tsx +5 -0
  74. package/templates/nextjs/optional/sections/blog/inject/app__page.tsx +5 -0
  75. package/templates/nextjs/optional/sections/blog/inject/constants__common.ts +2 -0
  76. package/templates/nextjs/optional/sections/contact/files/components/sections/contact-section.tsx +79 -0
  77. package/templates/nextjs/optional/sections/contact/inject/app__[lang]__page.tsx +5 -0
  78. package/templates/nextjs/optional/sections/contact/inject/app__page.tsx +5 -0
  79. package/templates/nextjs/optional/sections/contact/inject/constants__common.ts +2 -0
  80. package/templates/nextjs/optional/tanstack-query/files/lib/custom-fetch.ts +9 -0
  81. package/templates/nextjs/optional/tanstack-query/files/lib/query-client.ts +21 -0
  82. package/templates/nextjs/optional/tanstack-query/inject/components__providers.tsx +9 -0
  83. package/templates/nextjs/optional/tanstack-query/pkg.json +5 -0
  84. package/templates/nextjs/optional/zustand/files/store/ui-store.ts +16 -0
  85. package/templates/nextjs/optional/zustand/inject/components__providers.tsx +3 -0
  86. package/templates/nextjs/optional/zustand/pkg.json +5 -0
  87. package/templates/nextjs/themes/dark.css +36 -0
  88. package/templates/nextjs/themes/forest.css +58 -0
  89. package/templates/nextjs/themes/ocean.css +58 -0
  90. package/templates/nextjs/themes/pila.css +75 -0
  91. package/templates/nextjs/themes/purple.css +58 -0
@@ -0,0 +1,127 @@
1
+ #!/bin/bash
2
+
3
+ set -euo pipefail
4
+
5
+ # Colors for output
6
+ RED='\033[0;31m'
7
+ GREEN='\033[0;32m'
8
+ YELLOW='\033[1;33m'
9
+ NC='\033[0m' # No Color
10
+
11
+ # Configuration
12
+ APP_NAME="${APP_NAME:-__PROJECT_NAME__}"
13
+ APP_TAG="${APP_TAG:-$(git rev-parse --short HEAD)-$(date +%s)}"
14
+ APP_REGISTRY="${APP_REGISTRY:-registry.example.com}"
15
+ GITOPS_NAMESPACE="${GITOPS_NAMESPACE:-dev}"
16
+ DOCKERFILE="${DOCKERFILE:-Dockerfile}"
17
+ BUILD_CONTEXT="${BUILD_CONTEXT:-.}"
18
+ PUSH_IMAGE="${PUSH_IMAGE:-false}"
19
+ FAIL_ON_VULN="${FAIL_ON_VULN:-true}"
20
+ TRIVY_SEVERITY="${TRIVY_SEVERITY:-HIGH,CRITICAL,MEDIUM}"
21
+
22
+ # Construct image name
23
+ APP_IMAGE="${APP_REGISTRY}/${GITOPS_NAMESPACE}/${APP_NAME}"
24
+
25
+ echo "${GREEN}🐳 Docker Build & Scan Script${NC}"
26
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
27
+ echo "App Name: ${APP_NAME}"
28
+ echo "Image Tag: ${APP_TAG}"
29
+ echo "Image: ${APP_IMAGE}:${APP_TAG}"
30
+ echo "Dockerfile: ${DOCKERFILE}"
31
+ echo "Context: ${BUILD_CONTEXT}"
32
+ echo "Push Image: ${PUSH_IMAGE}"
33
+ echo "Fail on Vuln: ${FAIL_ON_VULN}"
34
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
35
+ echo ""
36
+
37
+ # Check if Docker is running
38
+ if ! docker info > /dev/null 2>&1; then
39
+ echo "${RED}❌ Docker is not running. Please start Docker and try again.${NC}"
40
+ exit 1
41
+ fi
42
+
43
+ # Check if Trivy is available
44
+ if ! command -v trivy > /dev/null 2>&1; then
45
+ echo "${YELLOW}⚠️ Trivy not found. Installing via Docker...${NC}"
46
+ USE_DOCKER_TRIVY=true
47
+ else
48
+ USE_DOCKER_TRIVY=false
49
+ fi
50
+
51
+ # Step 1: Build Docker image
52
+ echo "${GREEN}📦 Step 1: Building Docker image...${NC}"
53
+ if docker build \
54
+ -f "${DOCKERFILE}" \
55
+ -t "${APP_IMAGE}:${APP_TAG}" \
56
+ -t "${APP_IMAGE}:latest" \
57
+ "${BUILD_CONTEXT}"; then
58
+ echo "${GREEN}✅ Docker image built successfully${NC}"
59
+ else
60
+ echo "${RED}❌ Docker build failed${NC}"
61
+ exit 1
62
+ fi
63
+
64
+ echo ""
65
+
66
+ # Step 2: Download Trivy HTML template
67
+ echo "${GREEN}🔍 Step 2: Preparing Trivy scan...${NC}"
68
+ HTML_TEMPLATE="html.tpl"
69
+ if [ ! -f "${HTML_TEMPLATE}" ]; then
70
+ echo "Downloading Trivy HTML template..."
71
+ curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/html.tpl -o "${HTML_TEMPLATE}" || {
72
+ echo "${YELLOW}⚠️ Failed to download template, using default format${NC}"
73
+ HTML_TEMPLATE=""
74
+ }
75
+ fi
76
+
77
+ echo ""
78
+
79
+ # Step 3: Scan image with Trivy
80
+ echo "${GREEN}🔍 Step 3: Scanning image with Trivy...${NC}"
81
+ REPORT_FILE="trivy-${APP_NAME}-report.html"
82
+ SARIF_FILE="trivy-${APP_NAME}-report.sarif"
83
+ JSON_FILE="trivy-${APP_NAME}-report.json"
84
+
85
+ if [ "$USE_DOCKER_TRIVY" = true ]; then
86
+ docker run --rm \
87
+ -v /var/run/docker.sock:/var/run/docker.sock \
88
+ -v "$(pwd):/output" \
89
+ aquasecurity/trivy:latest \
90
+ image --format json --output "/output/${JSON_FILE}" \
91
+ --severity "${TRIVY_SEVERITY}" "${APP_IMAGE}:${APP_TAG}" || true
92
+ else
93
+ trivy image --format json --output "${JSON_FILE}" \
94
+ --severity "${TRIVY_SEVERITY}" "${APP_IMAGE}:${APP_TAG}" || true
95
+ fi
96
+
97
+ echo ""
98
+
99
+ # Step 4: Check for vulnerabilities
100
+ echo "${GREEN}📊 Step 4: Analyzing scan results...${NC}"
101
+
102
+ if [ -f "${JSON_FILE}" ]; then
103
+ VULN_COUNT=$(jq '[.Results[]?.Vulnerabilities[]?] | length' "${JSON_FILE}" 2>/dev/null || echo "0")
104
+ CRITICAL_COUNT=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' "${JSON_FILE}" 2>/dev/null || echo "0")
105
+ HIGH_COUNT=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH")] | length' "${JSON_FILE}" 2>/dev/null || echo "0")
106
+
107
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
108
+ echo "Scan Results:"
109
+ echo " Total Vulnerabilities: ${VULN_COUNT}"
110
+ echo " Critical: ${CRITICAL_COUNT}"
111
+ echo " High: ${HIGH_COUNT}"
112
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
113
+
114
+ if [ "${FAIL_ON_VULN}" = "true" ] && [ "${VULN_COUNT}" -gt 0 ]; then
115
+ echo "${RED}🛑 Vulnerabilities found! Set FAIL_ON_VULN=false to continue.${NC}"
116
+ exit 1
117
+ elif [ "${VULN_COUNT}" -gt 0 ]; then
118
+ echo "${YELLOW}⚠️ Vulnerabilities found but continuing (FAIL_ON_VULN=false)${NC}"
119
+ else
120
+ echo "${GREEN}✅ No vulnerabilities found!${NC}"
121
+ fi
122
+ else
123
+ echo "${YELLOW}⚠️ Could not parse scan results${NC}"
124
+ fi
125
+
126
+ echo ""
127
+ echo "${GREEN}✅ Build and scan completed successfully!${NC}"
@@ -0,0 +1,86 @@
1
+ #!/bin/bash
2
+ # Lighthouse CI check — runs against the production build
3
+ # Requires: Next.js build complete, @lhci/cli installed
4
+
5
+ set -euo pipefail
6
+
7
+ RED='\033[0;31m'
8
+ GREEN='\033[0;32m'
9
+ YELLOW='\033[1;33m'
10
+ NC='\033[0m'
11
+
12
+ PORT="${LIGHTHOUSE_PORT:-3000}"
13
+ START_TIMEOUT=30 # seconds to wait for server to be ready
14
+
15
+ # Check if lhci is available
16
+ if ! npx lhci --version > /dev/null 2>&1; then
17
+ echo "${RED}❌ @lhci/cli not found. Run: npm install --save-dev @lhci/cli${NC}"
18
+ exit 1
19
+ fi
20
+
21
+ # Check if build output exists
22
+ if [ ! -d ".next" ]; then
23
+ echo "${RED}❌ No .next directory found. Run 'npm run build' first.${NC}"
24
+ exit 1
25
+ fi
26
+
27
+ # Start Next.js production server in background
28
+ echo "Starting Next.js server on port ${PORT}..."
29
+ npm run start -- --port "${PORT}" &
30
+ SERVER_PID=$!
31
+
32
+ # Ensure server is killed on script exit
33
+ cleanup() {
34
+ echo "Stopping server (PID ${SERVER_PID})..."
35
+ kill "${SERVER_PID}" 2>/dev/null || true
36
+ }
37
+ trap cleanup EXIT
38
+
39
+ # Wait for server to be ready
40
+ echo "Waiting for server to be ready..."
41
+ ELAPSED=0
42
+ until curl -sf "http://localhost:${PORT}" > /dev/null 2>&1; do
43
+ if [ "${ELAPSED}" -ge "${START_TIMEOUT}" ]; then
44
+ echo "${RED}❌ Server did not start within ${START_TIMEOUT}s${NC}"
45
+ exit 1
46
+ fi
47
+ sleep 1
48
+ ELAPSED=$((ELAPSED + 1))
49
+ done
50
+ echo "${GREEN}Server ready after ${ELAPSED}s${NC}"
51
+
52
+ # Run Lighthouse CI
53
+ echo "Running Lighthouse CI..."
54
+ npx lhci autorun --config=.lighthouserc.json
55
+
56
+ # Print score summary from the latest LHR JSON
57
+ echo ""
58
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
59
+ echo " Lighthouse Score Summary"
60
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
61
+ node -e "
62
+ const fs = require('fs');
63
+ const dir = '.lighthouseci';
64
+ const lhrs = fs.readdirSync(dir).filter(f => f.startsWith('lhr-') && f.endsWith('.json'));
65
+ if (!lhrs.length) { console.log('No LHR results found.'); process.exit(0); }
66
+ const lhr = JSON.parse(fs.readFileSync(dir + '/' + lhrs[lhrs.length - 1], 'utf8'));
67
+ const cats = lhr.categories;
68
+ const score = (s) => Math.round(s * 100);
69
+ const icon = (s) => s >= 90 ? '🟢' : s >= 50 ? '🟡' : '🔴';
70
+ const fmt = (label, s) => console.log(icon(s) + ' ' + label.padEnd(18) + s);
71
+ fmt('Performance', score(cats.performance.score));
72
+ fmt('Accessibility', score(cats.accessibility.score));
73
+ fmt('Best Practices', score(cats['best-practices'].score));
74
+ fmt('SEO', score(cats.seo.score));
75
+ console.log('');
76
+ const audits = lhr.audits;
77
+ const ms = (v) => v >= 1000 ? (v/1000).toFixed(1) + 's' : Math.round(v) + 'ms';
78
+ console.log(' FCP : ' + ms(audits['first-contentful-paint'].numericValue));
79
+ console.log(' LCP : ' + ms(audits['largest-contentful-paint'].numericValue));
80
+ console.log(' TBT : ' + ms(audits['total-blocking-time'].numericValue));
81
+ console.log(' CLS : ' + audits['cumulative-layout-shift'].numericValue.toFixed(3));
82
+ console.log(' TTI : ' + ms(audits['interactive'].numericValue));
83
+ "
84
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
85
+
86
+ echo "${GREEN}✅ Lighthouse checks completed${NC}"
@@ -0,0 +1,63 @@
1
+ /* Theme file — auto-generated by create-landing-app */
2
+ /* This file is replaced by the theme preset you selected during scaffolding */
3
+ /* To rebrand: edit the CSS variables below */
4
+
5
+ /* Pila Theme (default) — white + deep blue */
6
+ :root {
7
+ --radius: 0.625rem;
8
+ --background: oklch(1 0 0);
9
+ --foreground: oklch(0.147 0.004 49.25);
10
+ --card: oklch(1 0 0);
11
+ --card-foreground: oklch(0.147 0.004 49.25);
12
+ --popover: oklch(1 0 0);
13
+ --popover-foreground: oklch(0.147 0.004 49.25);
14
+ --primary: oklch(0.45 0.18 250);
15
+ --primary-foreground: oklch(0.98 0 0);
16
+ --secondary: oklch(0.97 0.015 210);
17
+ --secondary-foreground: oklch(0.216 0.006 56.043);
18
+ --muted: oklch(0.97 0.015 210);
19
+ --muted-foreground: oklch(0.553 0.013 58.071);
20
+ --accent: oklch(0.95 0.03 220);
21
+ --accent-foreground: oklch(0.45 0.18 250);
22
+ --destructive: oklch(0.577 0.245 27.325);
23
+ --border: oklch(0.88 0.04 220);
24
+ --input: oklch(0.88 0.04 220);
25
+ --ring: oklch(0.65 0.12 230);
26
+ --brand-primary: #1849a9;
27
+ --brand-action: #1570ef;
28
+ --brand-footer-from: #1764eb;
29
+ --brand-footer-to: #002d7b;
30
+ --brand-surface: #daf8ff;
31
+ --brand-border: #d1e9ff;
32
+ --brand-gradient-from: #327eff;
33
+ --brand-gradient-to: #ffffff;
34
+ }
35
+
36
+ .dark {
37
+ --background: oklch(0.1 0.02 250);
38
+ --foreground: oklch(0.985 0.001 106.423);
39
+ --card: oklch(0.15 0.03 250);
40
+ --card-foreground: oklch(0.985 0.001 106.423);
41
+ --popover: oklch(0.15 0.03 250);
42
+ --popover-foreground: oklch(0.985 0.001 106.423);
43
+ --primary: oklch(0.65 0.16 250);
44
+ --primary-foreground: oklch(0.1 0.02 250);
45
+ --secondary: oklch(0.2 0.03 250);
46
+ --secondary-foreground: oklch(0.985 0.001 106.423);
47
+ --muted: oklch(0.2 0.03 250);
48
+ --muted-foreground: oklch(0.6 0.06 250);
49
+ --accent: oklch(0.2 0.03 250);
50
+ --accent-foreground: oklch(0.985 0.001 106.423);
51
+ --destructive: oklch(0.704 0.191 22.216);
52
+ --border: oklch(1 0 0 / 10%);
53
+ --input: oklch(1 0 0 / 15%);
54
+ --ring: oklch(0.553 0.013 58.071);
55
+ --brand-primary: #6aaeff;
56
+ --brand-action: #3b82f6;
57
+ --brand-footer-from: #1e3a8a;
58
+ --brand-footer-to: #0f172a;
59
+ --brand-surface: #1e293b;
60
+ --brand-border: #334155;
61
+ --brand-gradient-from: #6aaeff;
62
+ --brand-gradient-to: #dbeafe;
63
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "plugins": [{ "name": "next" }],
17
+ "paths": { "@/*": ["./*"] }
18
+ },
19
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
20
+ "exclude": ["node_modules"]
21
+ }
@@ -0,0 +1,16 @@
1
+ export interface NavLink {
2
+ label: string;
3
+ href: string;
4
+ }
5
+
6
+ export interface Feature {
7
+ icon: React.ReactNode;
8
+ title: string;
9
+ description: string;
10
+ }
11
+
12
+ export interface SocialLinks {
13
+ twitter?: string;
14
+ github?: string;
15
+ linkedin?: string;
16
+ }
@@ -0,0 +1,6 @@
1
+ node_modules
2
+ .next
3
+ .git
4
+ *.log
5
+ .env
6
+ .env.local
@@ -0,0 +1,36 @@
1
+ # Stage 1: Install dependencies
2
+ FROM node:20-alpine AS deps
3
+ RUN corepack enable
4
+ WORKDIR /app
5
+ COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* bun.lockb* ./
6
+ RUN \
7
+ if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
8
+ elif [ -f package-lock.json ]; then npm ci; \
9
+ elif [ -f pnpm-lock.yaml ]; then pnpm i --frozen-lockfile; \
10
+ elif [ -f bun.lockb ]; then bun install --frozen-lockfile; \
11
+ else echo "No lockfile found." && exit 1; \
12
+ fi
13
+
14
+ # Stage 2: Build
15
+ FROM node:20-alpine AS builder
16
+ WORKDIR /app
17
+ COPY --from=deps /app/node_modules ./node_modules
18
+ COPY . .
19
+ RUN yarn build
20
+
21
+ # Stage 3: Production runner (standalone output)
22
+ FROM node:20-alpine AS runner
23
+ WORKDIR /app
24
+ ENV NODE_ENV=production
25
+ ENV PORT=3000
26
+
27
+ # Run as non-root user for security (OWASP A05)
28
+ RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
29
+ USER nextjs
30
+
31
+ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
32
+ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
33
+ COPY --from=builder --chown=nextjs:nodejs /app/public ./public
34
+
35
+ EXPOSE 3000
36
+ CMD ["node", "server.js"]
@@ -0,0 +1,9 @@
1
+ services:
2
+ web:
3
+ build: .
4
+ ports:
5
+ - "3000:3000"
6
+ environment:
7
+ - NODE_ENV=production
8
+ - NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
9
+ restart: unless-stopped
@@ -0,0 +1,19 @@
1
+ import Providers from "@/components/providers";
2
+ import { getDictionary } from "@/get-dictionary";
3
+ import { i18n, type Locale } from "@/i18n-config";
4
+
5
+ export async function generateStaticParams() {
6
+ return i18n.locales.map((locale) => ({ lang: locale }));
7
+ }
8
+
9
+ export default async function Layout({
10
+ children,
11
+ params,
12
+ }: {
13
+ children: React.ReactNode;
14
+ params: Promise<{ lang: string }>;
15
+ }) {
16
+ const { lang } = await params;
17
+ const dict = await getDictionary(lang as Locale);
18
+ return <Providers dict={dict}>{children}</Providers>;
19
+ }
@@ -0,0 +1,15 @@
1
+ import HeroSection from "@/components/sections/hero-section";
2
+ import FeaturesSection from "@/components/sections/features-section";
3
+ import FooterSection from "@/components/sections/footer-section";
4
+ // __PAGE_IMPORTS__
5
+
6
+ export default function Home() {
7
+ return (
8
+ <main>
9
+ <HeroSection />
10
+ <FeaturesSection />
11
+ {/* __PAGE_SECTIONS__ */}
12
+ <FooterSection />
13
+ </main>
14
+ );
15
+ }
@@ -0,0 +1,39 @@
1
+ "use client";
2
+ import Link from "next/link";
3
+ import { useParams, usePathname } from "next/navigation";
4
+ import { i18n, type Locale } from "@/i18n-config";
5
+
6
+ const LOCALE_LABELS: Record<Locale, string> = {
7
+ en: "EN",
8
+ vi: "VI",
9
+ };
10
+
11
+ // Build the same page URL but with a different locale prefix
12
+ function buildLocalePath(pathname: string, targetLocale: Locale, currentLocale: string): string {
13
+ if (pathname === `/${currentLocale}`) return `/${targetLocale}`;
14
+ return pathname.replace(`/${currentLocale}/`, `/${targetLocale}/`);
15
+ }
16
+
17
+ export default function LanguageSwitcher() {
18
+ const pathname = usePathname();
19
+ const params = useParams();
20
+ const currentLocale = (params?.lang as Locale) ?? i18n.defaultLocale;
21
+
22
+ return (
23
+ <div className="hidden items-center gap-1 rounded-md border border-border p-0.5 md:flex">
24
+ {i18n.locales.map((locale) => (
25
+ <Link
26
+ key={locale}
27
+ href={buildLocalePath(pathname, locale as Locale, currentLocale)}
28
+ className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
29
+ currentLocale === locale
30
+ ? "bg-primary text-primary-foreground"
31
+ : "text-muted-foreground hover:text-foreground"
32
+ }`}
33
+ >
34
+ {LOCALE_LABELS[locale as Locale] ?? locale.toUpperCase()}
35
+ </Link>
36
+ ))}
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,41 @@
1
+ "use client";
2
+ import { useState } from "react";
3
+ import Link from "next/link";
4
+ import { Menu, X } from "lucide-react";
5
+ import { NAV_LINKS } from "@/constants/common";
6
+ import { Button } from "@/components/ui/button";
7
+ import { cn } from "@/lib/utils";
8
+ import { useDictionary } from "@/lib/dict-context";
9
+
10
+ export default function NavbarMobile() {
11
+ const [open, setOpen] = useState(false);
12
+ const dict = useDictionary();
13
+
14
+ return (
15
+ <div className="md:hidden">
16
+ <button onClick={() => setOpen(!open)} aria-label="Toggle menu">
17
+ {open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
18
+ </button>
19
+
20
+ {/* Slide-down mobile menu */}
21
+ <div
22
+ className={cn(
23
+ "absolute inset-x-0 top-16 flex flex-col gap-4 border-b bg-background p-6 transition-all duration-200",
24
+ open ? "visible opacity-100" : "invisible pointer-events-none opacity-0"
25
+ )}
26
+ >
27
+ {NAV_LINKS.map((link) => (
28
+ <Link
29
+ key={link.href}
30
+ href={link.href}
31
+ onClick={() => setOpen(false)}
32
+ className="text-sm font-medium"
33
+ >
34
+ {dict.nav[link.href.slice(1) as keyof typeof dict.nav] ?? link.label}
35
+ </Link>
36
+ ))}
37
+ <Button className="w-full">{dict.nav.getStarted}</Button>
38
+ </div>
39
+ </div>
40
+ );
41
+ }
@@ -0,0 +1,41 @@
1
+ "use client";
2
+ import Link from "next/link";
3
+ import { NAV_LINKS, SITE_NAME } from "@/constants/common";
4
+ import NavbarMobile from "./navbar-mobile";
5
+ import { Button } from "@/components/ui/button";
6
+ import LanguageSwitcher from "./language-switcher";
7
+ import { useDictionary } from "@/lib/dict-context";
8
+
9
+ export default function Navbar() {
10
+ const dict = useDictionary();
11
+ return (
12
+ <header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/80 backdrop-blur-sm header-shadow">
13
+ <div className="content-container flex h-16 items-center justify-between">
14
+ {/* Logo */}
15
+ <Link href="/" className="flex items-center gap-2 text-xl font-bold text-primary">
16
+ {SITE_NAME}
17
+ </Link>
18
+
19
+ {/* Desktop nav */}
20
+ <nav className="hidden items-center gap-6 md:flex">
21
+ {NAV_LINKS.map((link) => (
22
+ <Link
23
+ key={link.href}
24
+ href={link.href}
25
+ className="text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
26
+ >
27
+ {dict.nav[link.href.slice(1) as keyof typeof dict.nav] ?? link.label}
28
+ </Link>
29
+ ))}
30
+ </nav>
31
+
32
+ {/* CTA + mobile toggle */}
33
+ <div className="flex items-center gap-3">
34
+ <LanguageSwitcher />
35
+ <Button className="hidden md:flex">{dict.nav.getStarted}</Button>
36
+ <NavbarMobile />
37
+ </div>
38
+ </div>
39
+ </header>
40
+ );
41
+ }
@@ -0,0 +1,16 @@
1
+ "use client";
2
+ import { DictProvider } from "@/lib/dict-context";
3
+ import type en from "@/dictionaries/en.json";
4
+
5
+ type Dictionary = typeof en;
6
+
7
+ export default function Providers({
8
+ children,
9
+ dict,
10
+ }: {
11
+ children: React.ReactNode;
12
+ dict?: Dictionary;
13
+ }) {
14
+ const content = <>{children}</>;
15
+ return dict ? <DictProvider dict={dict}>{content}</DictProvider> : content;
16
+ }
@@ -0,0 +1,80 @@
1
+ "use client";
2
+ import { motion } from "motion/react";
3
+ import { Zap, Shield, Palette, Globe, Code, Rocket } from "lucide-react";
4
+ import { useDictionary } from "@/lib/dict-context";
5
+
6
+ const FEATURES = [
7
+ {
8
+ icon: Zap,
9
+ title: "Lightning fast",
10
+ description: "Built on Next.js 15 with Turbopack. Dev server starts in milliseconds.",
11
+ },
12
+ {
13
+ icon: Shield,
14
+ title: "Secure by default",
15
+ description: "Best-practice security headers, no inline scripts, CSP-ready.",
16
+ },
17
+ {
18
+ icon: Palette,
19
+ title: "Fully themeable",
20
+ description: "5 beautiful presets. One CSS file to rebrand completely.",
21
+ },
22
+ {
23
+ icon: Globe,
24
+ title: "i18n ready",
25
+ description: "Optional dictionary-based translation. Zero extra dependencies.",
26
+ },
27
+ {
28
+ icon: Code,
29
+ title: "Clean code",
30
+ description: "TypeScript, ESLint, component-first architecture. Easy to extend.",
31
+ },
32
+ {
33
+ icon: Rocket,
34
+ title: "Deploy anywhere",
35
+ description: "Docker-ready standalone output. Ships to any VPS in minutes.",
36
+ },
37
+ ];
38
+
39
+ export default function FeaturesSection() {
40
+ const dict = useDictionary();
41
+ return (
42
+ <section id="features" className="bg-secondary/30 py-24">
43
+ <div className="content-container">
44
+ {/* Header */}
45
+ <motion.div
46
+ initial={{ opacity: 0, y: 30 }}
47
+ whileInView={{ opacity: 1, y: 0 }}
48
+ viewport={{ once: true }}
49
+ transition={{ duration: 0.6 }}
50
+ className="mb-16 space-y-4 text-center"
51
+ >
52
+ <h2 className="text-4xl font-bold">{dict.features.title}</h2>
53
+ <p className="mx-auto max-w-2xl text-lg text-muted-foreground">
54
+ {dict.features.subtitle}
55
+ </p>
56
+ </motion.div>
57
+
58
+ {/* Feature grid */}
59
+ <div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
60
+ {FEATURES.map((feature, i) => (
61
+ <motion.div
62
+ key={feature.title}
63
+ initial={{ opacity: 0, y: 30 }}
64
+ whileInView={{ opacity: 1, y: 0 }}
65
+ viewport={{ once: true }}
66
+ transition={{ duration: 0.5, delay: i * 0.08 }}
67
+ className="group rounded-2xl border border-border bg-background p-6 transition-all duration-300 hover:border-primary/40 hover:shadow-md"
68
+ >
69
+ <div className="mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-accent transition-colors group-hover:bg-primary/10">
70
+ <feature.icon className="h-6 w-6 text-primary" />
71
+ </div>
72
+ <h3 className="mb-2 text-lg font-semibold">{feature.title}</h3>
73
+ <p className="text-sm leading-relaxed text-muted-foreground">{feature.description}</p>
74
+ </motion.div>
75
+ ))}
76
+ </div>
77
+ </div>
78
+ </section>
79
+ );
80
+ }