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,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,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,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
|
+
}
|