first commit
This commit is contained in:
parent
06b9d6afe6
commit
a6f9257062
22
.env.example
Normal file
22
.env.example
Normal file
@ -0,0 +1,22 @@
|
||||
# SEO Configuration - 站点地图和 SEO 配置
|
||||
NEXT_PUBLIC_BASE_URL=https://your-domain.com
|
||||
NEXT_PUBLIC_SITE_NAME=Eco Life
|
||||
NEXT_PUBLIC_TWITTER_HANDLE=@ecolife
|
||||
|
||||
# Search Engine Verification - 搜索引擎验证
|
||||
GOOGLE_SITE_VERIFICATION=your-google-verification-code
|
||||
BING_SITE_VERIFICATION=your-bing-verification-code
|
||||
YANDEX_SITE_VERIFICATION=your-yandex-verification-code
|
||||
BAIDU_SITE_VERIFICATION=your-baidu-verification-code
|
||||
|
||||
# Analytics - 分析工具
|
||||
GOOGLE_ANALYTICS_ID=GA-XXXXXXXXX
|
||||
GOOGLE_TAG_MANAGER_ID=GTM-XXXXXXX
|
||||
|
||||
# Sitemap Configuration - 站点地图配置
|
||||
SITEMAP_CACHE_DURATION=3600
|
||||
SITEMAP_MAX_URLS=50000
|
||||
SITEMAP_ENABLE_VALIDATION=true
|
||||
|
||||
# Development - 开发环境
|
||||
NODE_ENV=production
|
||||
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
@ -0,0 +1,39 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
.yarn/install-state.gz
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# vscode
|
||||
.vscode
|
||||
11
.prettierrc
Normal file
11
.prettierrc
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"jsxSingleQuote": false,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
85
README.md
Normal file
85
README.md
Normal file
@ -0,0 +1,85 @@
|
||||
# Eco-Life - A Multilingual Static Next.js Project
|
||||
|
||||
<p align="center">
|
||||
<img src="public/images/logo-text.png" width="300" />
|
||||
</p>
|
||||
|
||||
This project is a statically exported, multilingual website built with [Next.js](https://nextjs.org/) (App Router), [Tailwind CSS](https://tailwindcss.com/), and [ShadCN/UI](https://ui.shadcn.com/). It serves as a showcase for "Eco-Life," a fictional environmental initiative.
|
||||
|
||||
## Features
|
||||
|
||||
- **Static Site Generation (SSG)**: The entire site is statically exported for optimal performance and security. Configured with `output: 'export'`.
|
||||
- **Multilingual Support (i18n)**: Fully supports multiple languages through Next.js's App Router i18n capabilities.
|
||||
- Supported Languages: English (en), Simplified Chinese (zh-CN), Traditional Chinese (zh-TW), Korean (ko), and Japanese (ja).
|
||||
- **Automatic Language Detection**: The root of the site automatically detects the user's browser language and redirects to the appropriate language version (e.g., `/en`, `/zh-CN`).
|
||||
- **Dynamic Content**: Includes a blog with dynamically generated static pages for each post and language.
|
||||
- **SEO Optimized**: Automatically generates `sitemap.xml` and `robots.ts` for better search engine visibility.
|
||||
- **Modern Tech Stack**: Built with the latest features of Next.js, React Server Components, and Client Components.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) (v18 or later)
|
||||
- A package manager like [npm](https://www.npmjs.com/), [yarn](https://yarnpkg.com/), or [bun](https://bun.sh/)
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
```
|
||||
2. Navigate to the project directory:
|
||||
```bash
|
||||
cd <project-directory>
|
||||
```
|
||||
3. Install the dependencies:
|
||||
```bash
|
||||
npm install
|
||||
# or
|
||||
yarn install
|
||||
# or
|
||||
bun install
|
||||
```
|
||||
|
||||
### Running the Development Server
|
||||
|
||||
To view the project in development mode with hot-reloading:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) in your browser to see the result.
|
||||
|
||||
## Building for Production
|
||||
|
||||
To build the static site for production:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
# or
|
||||
yarn build
|
||||
# or
|
||||
bun build
|
||||
```
|
||||
|
||||
This command will generate a `build` directory containing all the static files for the website.
|
||||
|
||||
### Previewing the Static Site
|
||||
|
||||
After building, you can preview the local static site by running a simple HTTP server in the `build` directory. For example, using `serve`:
|
||||
|
||||
```bash
|
||||
# Install serve if you don't have it
|
||||
npm install -g serve
|
||||
|
||||
# Serve the build directory
|
||||
serve build
|
||||
```
|
||||
|
||||
This will start a server, and you can view your statically exported site at the provided local URL (e.g., `http://localhost:3000`).
|
||||
341
app/[locale]/blog/[slug]/BlogPostClient.tsx
Normal file
341
app/[locale]/blog/[slug]/BlogPostClient.tsx
Normal file
@ -0,0 +1,341 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Navigation from '../../../../components/Navigation';
|
||||
import FloatingLanguageSwitcher from '../../../../components/FloatingLanguageSwitcher';
|
||||
import Footer from '../../../../components/Footer';
|
||||
import { Locale } from '../../../../lib/i18n';
|
||||
import { getTranslations, Translations } from '../../../../lib/translations';
|
||||
import { updateDocumentMeta } from '../../../../lib/seo-utils';
|
||||
|
||||
// Gentle Particle Background Component
|
||||
function GentleParticleBackground() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size
|
||||
const resizeCanvas = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
};
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
|
||||
// Gentle particle system
|
||||
const particles: Array<{
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
size: number;
|
||||
opacity: number;
|
||||
}> = [];
|
||||
|
||||
// Create particles
|
||||
for (let i = 0; i < 60; i++) {
|
||||
particles.push({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
vx: (Math.random() - 0.5) * 0.2,
|
||||
vy: (Math.random() - 0.5) * 0.2,
|
||||
size: Math.random() * 2 + 1,
|
||||
opacity: Math.random() * 0.3 + 0.1,
|
||||
});
|
||||
}
|
||||
|
||||
// Animation loop
|
||||
const animate = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
particles.forEach((particle) => {
|
||||
// Update position
|
||||
particle.x += particle.vx;
|
||||
particle.y += particle.vy;
|
||||
|
||||
// Wrap around edges
|
||||
if (particle.x < 0) particle.x = canvas.width;
|
||||
if (particle.x > canvas.width) particle.x = 0;
|
||||
if (particle.y < 0) particle.y = canvas.height;
|
||||
if (particle.y > canvas.height) particle.y = 0;
|
||||
|
||||
// Draw particle
|
||||
ctx.beginPath();
|
||||
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${particle.opacity})`;
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resizeCanvas);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type Post = Translations['blog']['posts']['featured']; // Assuming all posts have the same structure
|
||||
|
||||
export default function BlogPostClient({
|
||||
locale,
|
||||
post,
|
||||
t,
|
||||
}: {
|
||||
locale: Locale;
|
||||
post: Post | null;
|
||||
t: Translations;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
// Update meta tags when locale changes
|
||||
useEffect(() => {
|
||||
updateDocumentMeta(locale);
|
||||
}, [locale]);
|
||||
|
||||
if (!post) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold text-white mb-4">Post Not Found</h1>
|
||||
<Link
|
||||
href={`/${locale}/blog`}
|
||||
className="text-green-400 hover:text-green-300 underline"
|
||||
>
|
||||
Back to Blog
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case 'playerStories':
|
||||
return 'bg-amber-500';
|
||||
case 'development':
|
||||
return 'bg-green-500';
|
||||
case 'ecoFacts':
|
||||
return 'bg-emerald-500';
|
||||
default:
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryName = (category: string) => {
|
||||
switch (category) {
|
||||
case 'playerStories':
|
||||
return t.blog.categories.playerStories;
|
||||
case 'development':
|
||||
return t.blog.categories.development;
|
||||
case 'ecoFacts':
|
||||
return t.blog.categories.ecoFacts;
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative overflow-hidden">
|
||||
{/* Gentle Background with Overlay */}
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(30, 58, 138, 0.8), rgba(15, 23, 42, 0.9)), url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800"><defs><linearGradient id="gentle1" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:%234F46E5;stop-opacity:1" /><stop offset="50%" style="stop-color:%237C3AED;stop-opacity:1" /><stop offset="100%" style="stop-color:%231E40AF;stop-opacity:1" /></linearGradient></defs><rect width="1200" height="800" fill="url(%23gentle1)"/><path d="M0,400 Q300,300 600,350 T1200,300 L1200,800 L0,800 Z" fill="%23312E81" opacity="0.5"/><path d="M0,500 Q400,400 800,450 T1200,400 L1200,800 L0,800 Z" fill="%231E3A8A" opacity="0.3"/></svg>')`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Gentle Particle Background */}
|
||||
<GentleParticleBackground />
|
||||
|
||||
{/* Navigation */}
|
||||
<Navigation locale={locale} />
|
||||
|
||||
{/* Floating Language Switcher */}
|
||||
<FloatingLanguageSwitcher locale={locale} />
|
||||
|
||||
{/* Article Content */}
|
||||
<article className="relative z-10 pt-32 pb-20 px-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Back to Blog Link */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href={`/${locale}/blog`}
|
||||
className="inline-flex items-center text-white/80 hover:text-white transition-colors duration-200"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
{t.blog.backToBlog}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Article Header */}
|
||||
<header className="mb-12">
|
||||
<div className="mb-6">
|
||||
<span
|
||||
className={`${getCategoryColor(post.category)} text-white px-4 py-2 rounded-full text-sm font-medium`}
|
||||
>
|
||||
{getCategoryName(post.category)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-white mb-6 leading-tight">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap items-center text-white/70 text-sm space-x-6 mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{post.author}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{post.date}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{post.readTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xl text-white/90 leading-relaxed">{post.excerpt}</p>
|
||||
</header>
|
||||
|
||||
{/* Article Body */}
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-3xl p-8 md:p-12 border border-white/20 shadow-2xl">
|
||||
<div className="prose prose-lg prose-invert max-w-none">
|
||||
{post.content.split('\n\n').map((paragraph, index) => (
|
||||
<p key={index} className="text-white/90 leading-relaxed mb-6 text-lg">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Article Footer */}
|
||||
<footer className="mt-12 pt-8 border-t border-white/20">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center space-y-4 md:space-y-0">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-green-400 to-blue-500 rounded-full flex items-center justify-center">
|
||||
<span className="text-white font-bold text-lg">
|
||||
{post.author.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-medium">{post.author}</p>
|
||||
<p className="text-white/70 text-sm">
|
||||
Environmental Writer
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<button className="bg-white/20 hover:bg-white/30 text-white px-6 py-2 rounded-full transition-colors duration-200">
|
||||
Share
|
||||
</button>
|
||||
<button className="bg-green-500 hover:bg-green-600 text-white px-6 py-2 rounded-full transition-colors duration-200">
|
||||
Like
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Related Posts Section */}
|
||||
<section className="mt-16">
|
||||
<h3 className="text-2xl font-bold text-white mb-8">Related Posts</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Link href={`/${locale}/blog/post1`}>
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 border border-white/20 hover:bg-white/20 transition-all duration-200 cursor-pointer">
|
||||
<h4 className="text-white font-semibold mb-2">
|
||||
Player Spotlight: Building a Virtual Eco-City
|
||||
</h4>
|
||||
<p className="text-white/70 text-sm">
|
||||
Meet Alex, a player who created an incredible sustainable
|
||||
city...
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href={`/${locale}/blog/post2`}>
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 border border-white/20 hover:bg-white/20 transition-all duration-200 cursor-pointer">
|
||||
<h4 className="text-white font-semibold mb-2">
|
||||
10 Amazing Facts About Ocean Conservation
|
||||
</h4>
|
||||
<p className="text-white/70 text-sm">
|
||||
Dive into fascinating facts about our oceans...
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer locale={locale} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
app/[locale]/blog/[slug]/page.tsx
Normal file
36
app/[locale]/blog/[slug]/page.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { locales } from '../../../../lib/i18n';
|
||||
import { getBlogPostSlugs } from '../../../../lib/sitemap-generator';
|
||||
import { Locale } from '../../../../lib/i18n';
|
||||
import { getTranslations } from '../../../../lib/translations';
|
||||
import BlogPostClient from './BlogPostClient'; // Assuming the client component is in a separate file
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const slugs = getBlogPostSlugs();
|
||||
const params = locales.flatMap((locale) =>
|
||||
slugs.map((slug) => ({
|
||||
locale,
|
||||
slug,
|
||||
})),
|
||||
);
|
||||
return params;
|
||||
}
|
||||
|
||||
export default function BlogPostPage({ params }: { params: { locale: Locale; slug: string } }) {
|
||||
const t = getTranslations(params.locale);
|
||||
|
||||
const getPostData = (slug: string) => {
|
||||
const posts = {
|
||||
featured: t.blog.posts.featured,
|
||||
post1: t.blog.posts.post1,
|
||||
post2: t.blog.posts.post2,
|
||||
post3: t.blog.posts.post3,
|
||||
post4: t.blog.posts.post4,
|
||||
post5: t.blog.posts.post5,
|
||||
};
|
||||
return posts[slug as keyof typeof posts] || null;
|
||||
};
|
||||
|
||||
const post = getPostData(params.slug);
|
||||
|
||||
return <BlogPostClient locale={params.locale} post={post} t={t} />;
|
||||
}
|
||||
358
app/[locale]/blog/page.tsx
Normal file
358
app/[locale]/blog/page.tsx
Normal file
@ -0,0 +1,358 @@
|
||||
import { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { Locale } from '../../../lib/i18n';
|
||||
import { getTranslations } from '../../../lib/translations';
|
||||
import { generateCanonicalUrl, generateAlternateLinks } from '../../../lib/seo-utils';
|
||||
|
||||
interface BlogPageProps {
|
||||
params: {
|
||||
locale: Locale;
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: BlogPageProps): Promise<Metadata> {
|
||||
const t = getTranslations(params.locale);
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://your-domain.com';
|
||||
const canonicalUrl = generateCanonicalUrl('/blog', params.locale, baseUrl);
|
||||
const alternateLinks = generateAlternateLinks('/blog', baseUrl);
|
||||
|
||||
return {
|
||||
title: t.blog.title,
|
||||
description: t.blog.subtitle,
|
||||
alternates: {
|
||||
canonical: canonicalUrl,
|
||||
languages: Object.fromEntries(alternateLinks.map((link) => [link.hrefLang, link.href])),
|
||||
},
|
||||
openGraph: {
|
||||
title: t.blog.title,
|
||||
description: t.blog.subtitle,
|
||||
url: canonicalUrl,
|
||||
type: 'website',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function BlogPage({ params }: BlogPageProps) {
|
||||
const t = getTranslations(params.locale);
|
||||
|
||||
// 获取所有博客文章
|
||||
const blogPosts = [
|
||||
{
|
||||
id: 'featured',
|
||||
...t.blog.posts.featured,
|
||||
image: 'featured',
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: 'post1',
|
||||
...t.blog.posts.post1,
|
||||
image: 'post1',
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
id: 'post2',
|
||||
...t.blog.posts.post2,
|
||||
image: 'post2',
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
id: 'post3',
|
||||
...t.blog.posts.post3,
|
||||
image: 'post3',
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
id: 'post4',
|
||||
...t.blog.posts.post4,
|
||||
image: 'post4',
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
id: 'post5',
|
||||
...t.blog.posts.post5,
|
||||
image: 'post5',
|
||||
featured: false,
|
||||
},
|
||||
];
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case 'playerStories':
|
||||
return 'bg-amber-500';
|
||||
case 'development':
|
||||
return 'bg-green-500';
|
||||
case 'ecoFacts':
|
||||
return 'bg-emerald-500';
|
||||
default:
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryName = (category: string) => {
|
||||
switch (category) {
|
||||
case 'playerStories':
|
||||
return t.blog.categories.playerStories;
|
||||
case 'development':
|
||||
return t.blog.categories.development;
|
||||
case 'ecoFacts':
|
||||
return t.blog.categories.ecoFacts;
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
};
|
||||
|
||||
const getImageGradient = (imageId: string) => {
|
||||
const gradients = {
|
||||
featured: 'from-purple-500 via-blue-500 to-indigo-600',
|
||||
post1: 'from-amber-500 via-orange-500 to-red-500',
|
||||
post2: 'from-cyan-500 via-blue-500 to-indigo-500',
|
||||
post3: 'from-green-500 via-emerald-500 to-teal-500',
|
||||
post4: 'from-pink-500 via-rose-500 to-red-500',
|
||||
post5: 'from-violet-500 via-purple-500 to-indigo-500',
|
||||
};
|
||||
return gradients[imageId as keyof typeof gradients] || 'from-gray-500 to-gray-600';
|
||||
};
|
||||
|
||||
const featuredPost = blogPosts.find((post) => post.featured);
|
||||
const regularPosts = blogPosts.filter((post) => !post.featured);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-black">
|
||||
{/* Hero Section */}
|
||||
<section className="relative py-20 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-5xl md:text-6xl font-bold text-white mb-6">
|
||||
{t.blog.title}
|
||||
</h1>
|
||||
<p className="text-xl text-white/80 max-w-3xl mx-auto leading-relaxed">
|
||||
{t.blog.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="flex flex-wrap justify-center gap-4 mb-12">
|
||||
<button className="bg-white/10 backdrop-blur-md text-white px-6 py-3 rounded-full border border-white/20 hover:bg-white/20 transition-all duration-200">
|
||||
{t.blog.categories.all}
|
||||
</button>
|
||||
<button className="bg-amber-500/20 backdrop-blur-md text-amber-300 px-6 py-3 rounded-full border border-amber-500/30 hover:bg-amber-500/30 transition-all duration-200">
|
||||
{t.blog.categories.playerStories}
|
||||
</button>
|
||||
<button className="bg-green-500/20 backdrop-blur-md text-green-300 px-6 py-3 rounded-full border border-green-500/30 hover:bg-green-500/30 transition-all duration-200">
|
||||
{t.blog.categories.development}
|
||||
</button>
|
||||
<button className="bg-emerald-500/20 backdrop-blur-md text-emerald-300 px-6 py-3 rounded-full border border-emerald-500/30 hover:bg-emerald-500/30 transition-all duration-200">
|
||||
{t.blog.categories.ecoFacts}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured Post */}
|
||||
{featuredPost && (
|
||||
<section className="px-4 sm:px-6 lg:px-8 mb-20">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-white mb-4">
|
||||
{t.blog.featured}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Link href={`/${params.locale}/blog/${featuredPost.id}`}>
|
||||
<article className="group cursor-pointer">
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-3xl overflow-hidden border border-white/20 shadow-2xl hover:shadow-3xl transition-all duration-500 transform hover:scale-[1.02]">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-0">
|
||||
{/* Featured Post Image */}
|
||||
<div className="relative h-80 lg:h-96 overflow-hidden">
|
||||
<div
|
||||
className={`absolute inset-0 bg-gradient-to-br ${getImageGradient(featuredPost.image)} opacity-90`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/20"></div>
|
||||
{/* Animated particles */}
|
||||
<div className="absolute inset-0">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-2 h-2 bg-white/30 rounded-full animate-pulse group-hover:animate-bounce"
|
||||
style={{
|
||||
left: `${20 + i * 10}%`,
|
||||
top: `${30 + (i % 3) * 20}%`,
|
||||
animationDelay: `${i * 0.2}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-6 left-6">
|
||||
<span
|
||||
className={`${getCategoryColor(featuredPost.category)} text-white px-4 py-2 rounded-full text-sm font-medium shadow-lg`}
|
||||
>
|
||||
{getCategoryName(featuredPost.category)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute top-6 right-6">
|
||||
<div className="bg-white/20 backdrop-blur-sm rounded-full px-3 py-1">
|
||||
<span className="text-white text-sm font-medium">
|
||||
{t.blog.featured}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Featured Post Content */}
|
||||
<div className="p-8 lg:p-12 flex flex-col justify-center">
|
||||
<div className="text-white/70 text-sm mb-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span>{featuredPost.author}</span>
|
||||
<span>•</span>
|
||||
<span>{featuredPost.date}</span>
|
||||
<span>•</span>
|
||||
<span>{featuredPost.readTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-3xl font-bold text-white mb-6 group-hover:text-green-300 transition-colors duration-200 leading-tight">
|
||||
{featuredPost.title}
|
||||
</h3>
|
||||
<p className="text-white/80 mb-8 leading-relaxed text-lg">
|
||||
{featuredPost.excerpt}
|
||||
</p>
|
||||
<div className="flex items-center">
|
||||
<span className="text-green-300 font-medium group-hover:text-green-200 transition-colors duration-200 flex items-center">
|
||||
{t.blog.readMore}
|
||||
<svg
|
||||
className="w-5 h-5 ml-2 transform group-hover:translate-x-1 transition-transform duration-200"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Regular Posts Grid */}
|
||||
<section className="px-4 sm:px-6 lg:px-8 pb-20">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-white mb-4">
|
||||
{t.blog.categories.all}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{regularPosts.map((post) => (
|
||||
<Link key={post.id} href={`/${params.locale}/blog/${post.id}`}>
|
||||
<article className="group cursor-pointer h-full">
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl overflow-hidden border border-white/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:scale-[1.02] h-full flex flex-col">
|
||||
{/* Post Image */}
|
||||
<div className="relative h-48 overflow-hidden">
|
||||
<div
|
||||
className={`absolute inset-0 bg-gradient-to-br ${getImageGradient(post.image)} opacity-90`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/20"></div>
|
||||
{/* Mini particles */}
|
||||
<div className="absolute inset-0">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-1 h-1 bg-white/40 rounded-full animate-pulse group-hover:animate-ping"
|
||||
style={{
|
||||
left: `${30 + i * 15}%`,
|
||||
top: `${40 + i * 10}%`,
|
||||
animationDelay: `${i * 0.3}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-4 left-4">
|
||||
<span
|
||||
className={`${getCategoryColor(post.category)} text-white px-3 py-1 rounded-full text-xs font-medium`}
|
||||
>
|
||||
{getCategoryName(post.category)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Post Content */}
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
<div className="text-white/60 text-sm mb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span>{post.author}</span>
|
||||
<span>•</span>
|
||||
<span>{post.readTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-3 group-hover:text-green-300 transition-colors duration-200 line-clamp-2 flex-shrink-0">
|
||||
{post.title}
|
||||
</h3>
|
||||
<p className="text-white/70 mb-4 line-clamp-3 flex-1">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-auto">
|
||||
<span className="text-white/50 text-sm">
|
||||
{post.date}
|
||||
</span>
|
||||
<span className="text-green-300 font-medium group-hover:text-green-200 transition-colors duration-200 flex items-center">
|
||||
{t.blog.readMore}
|
||||
<svg
|
||||
className="w-4 h-4 ml-1 transform group-hover:translate-x-1 transition-transform duration-200"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Newsletter Subscription */}
|
||||
<section className="px-4 sm:px-6 lg:px-8 pb-20">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="bg-gradient-to-r from-green-500/20 to-emerald-500/20 backdrop-blur-md rounded-3xl p-8 lg:p-12 border border-green-500/30 text-center">
|
||||
<h2 className="text-3xl font-bold text-white mb-4">{t.blog.stayUpdated}</h2>
|
||||
<p className="text-white/80 mb-8 text-lg max-w-2xl mx-auto">
|
||||
{t.blog.stayUpdatedDesc}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center max-w-md mx-auto">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
className="flex-1 px-6 py-3 rounded-full bg-white/10 backdrop-blur-md border border-white/20 text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
/>
|
||||
<button className="bg-green-500 hover:bg-green-600 text-white px-8 py-3 rounded-full font-semibold transition-all duration-200 transform hover:scale-105 whitespace-nowrap">
|
||||
{t.blog.subscribe}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
513
app/[locale]/contact/page.tsx
Normal file
513
app/[locale]/contact/page.tsx
Normal file
@ -0,0 +1,513 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Navigation from '../../../components/Navigation';
|
||||
import FloatingLanguageSwitcher from '../../../components/FloatingLanguageSwitcher';
|
||||
import Footer from '../../../components/Footer';
|
||||
import { Locale } from '../../../lib/i18n';
|
||||
import { getTranslations } from '../../../lib/translations';
|
||||
import { updateDocumentMeta } from '../../../lib/seo-utils';
|
||||
|
||||
// Arctic Particle Background Component
|
||||
function ArcticParticleBackground() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size
|
||||
const resizeCanvas = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
};
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
|
||||
// Particle system for arctic theme
|
||||
const particles: Array<{
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
size: number;
|
||||
opacity: number;
|
||||
type: 'snow' | 'ice' | 'aurora';
|
||||
}> = [];
|
||||
|
||||
// Create particles
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const type = Math.random() < 0.7 ? 'snow' : Math.random() < 0.9 ? 'ice' : 'aurora';
|
||||
particles.push({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
vx: (Math.random() - 0.5) * (type === 'snow' ? 0.3 : 0.1),
|
||||
vy: type === 'snow' ? Math.random() * 0.5 + 0.2 : (Math.random() - 0.5) * 0.1,
|
||||
size:
|
||||
type === 'snow'
|
||||
? Math.random() * 3 + 1
|
||||
: type === 'ice'
|
||||
? Math.random() * 2 + 0.5
|
||||
: Math.random() * 4 + 2,
|
||||
opacity: type === 'aurora' ? Math.random() * 0.3 + 0.1 : Math.random() * 0.6 + 0.2,
|
||||
type,
|
||||
});
|
||||
}
|
||||
|
||||
// Animation loop
|
||||
const animate = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
particles.forEach((particle) => {
|
||||
// Update position
|
||||
particle.x += particle.vx;
|
||||
particle.y += particle.vy;
|
||||
|
||||
// Wrap around edges
|
||||
if (particle.x < -10) particle.x = canvas.width + 10;
|
||||
if (particle.x > canvas.width + 10) particle.x = -10;
|
||||
if (particle.y < -10) particle.y = canvas.height + 10;
|
||||
if (particle.y > canvas.height + 10) particle.y = -10;
|
||||
|
||||
// Draw particle based on type
|
||||
ctx.beginPath();
|
||||
|
||||
if (particle.type === 'snow') {
|
||||
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${particle.opacity})`;
|
||||
ctx.fill();
|
||||
} else if (particle.type === 'ice') {
|
||||
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(173, 216, 230, ${particle.opacity})`;
|
||||
ctx.fill();
|
||||
} else if (particle.type === 'aurora') {
|
||||
const gradient = ctx.createRadialGradient(
|
||||
particle.x,
|
||||
particle.y,
|
||||
0,
|
||||
particle.x,
|
||||
particle.y,
|
||||
particle.size * 3,
|
||||
);
|
||||
gradient.addColorStop(0, `rgba(0, 255, 150, ${particle.opacity})`);
|
||||
gradient.addColorStop(0.5, `rgba(0, 150, 255, ${particle.opacity * 0.5})`);
|
||||
gradient.addColorStop(1, `rgba(150, 0, 255, 0)`);
|
||||
|
||||
ctx.arc(particle.x, particle.y, particle.size * 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
}
|
||||
});
|
||||
|
||||
// Draw connections between ice particles
|
||||
particles
|
||||
.filter((p) => p.type === 'ice')
|
||||
.forEach((particle, i, iceParticles) => {
|
||||
iceParticles.slice(i + 1).forEach((otherParticle) => {
|
||||
const dx = particle.x - otherParticle.x;
|
||||
const dy = particle.y - otherParticle.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < 80) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(particle.x, particle.y);
|
||||
ctx.lineTo(otherParticle.x, otherParticle.y);
|
||||
ctx.strokeStyle = `rgba(173, 216, 230, ${0.1 * (1 - distance / 80)})`;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resizeCanvas);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
data-oid="vdts.76"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContactPage({ params }: { params: { locale: Locale } }) {
|
||||
const t = getTranslations(params.locale);
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
message: '',
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
|
||||
// Update meta tags when locale changes
|
||||
useEffect(() => {
|
||||
updateDocumentMeta(params.locale);
|
||||
}, [params.locale]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Simulate form submission
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
setSubmitStatus('success');
|
||||
setFormData({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
message: '',
|
||||
});
|
||||
} catch (error) {
|
||||
setSubmitStatus('error');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setTimeout(() => setSubmitStatus('idle'), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative overflow-hidden" data-oid="cxtwh67">
|
||||
{/* Arctic Background with Overlay */}
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(0, 50, 100, 0.7), rgba(0, 30, 60, 0.8)), url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800"><defs><linearGradient id="arctic1" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:%2387CEEB;stop-opacity:1" /><stop offset="50%" style="stop-color:%234682B4;stop-opacity:1" /><stop offset="100%" style="stop-color:%23191970;stop-opacity:1" /></linearGradient></defs><rect width="1200" height="800" fill="url(%23arctic1)"/><path d="M0,600 Q300,500 600,550 T1200,500 L1200,800 L0,800 Z" fill="%23F0F8FF"/><path d="M0,650 Q400,580 800,620 T1200,600 L1200,800 L0,800 Z" fill="%23E6F3FF"/><ellipse cx="200" cy="200" rx="60" ry="30" fill="%23FFFFFF" opacity="0.8"/><ellipse cx="800" cy="150" rx="80" ry="40" fill="%23FFFFFF" opacity="0.6"/><ellipse cx="1000" cy="250" rx="50" ry="25" fill="%23FFFFFF" opacity="0.7"/></svg>')`,
|
||||
}}
|
||||
data-oid="g8y9fqe"
|
||||
/>
|
||||
|
||||
{/* Arctic Particle Background */}
|
||||
<ArcticParticleBackground data-oid="cn3_8yw" />
|
||||
|
||||
{/* Navigation */}
|
||||
<Navigation locale={params.locale} data-oid="88otgww" />
|
||||
|
||||
{/* Floating Language Switcher */}
|
||||
<FloatingLanguageSwitcher locale={params.locale} data-oid="lzqtg48" />
|
||||
|
||||
{/* Contact Section */}
|
||||
<section className="relative z-10 py-20 px-4" data-oid="wlyxybl">
|
||||
<div className="max-w-6xl mx-auto" data-oid="8rk2.0s">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-16" data-oid="q1kwgw8">
|
||||
<h1
|
||||
className="text-5xl md:text-6xl font-bold text-white mb-6 leading-tight"
|
||||
data-oid="ek48zik"
|
||||
>
|
||||
{t.contact.title}
|
||||
</h1>
|
||||
<p
|
||||
className="text-xl md:text-2xl text-white/90 max-w-3xl mx-auto leading-relaxed"
|
||||
data-oid="1k-t_ie"
|
||||
>
|
||||
{t.contact.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12" data-oid="_xqiofz">
|
||||
{/* Contact Form */}
|
||||
<div
|
||||
className="bg-white/10 backdrop-blur-md rounded-3xl p-8 border border-white/20 shadow-2xl"
|
||||
data-oid="pkudnst"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-6" data-oid="dcdmf-y">
|
||||
{/* Name Fields */}
|
||||
<div
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
data-oid="p34ti:p"
|
||||
>
|
||||
<div data-oid="u_cyg_s">
|
||||
<input
|
||||
type="text"
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleInputChange}
|
||||
placeholder={t.contact.form.firstName}
|
||||
required
|
||||
className="w-full px-4 py-3 rounded-xl bg-white/20 border border-white/30 text-white placeholder-white/70 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all duration-200"
|
||||
data-oid="-mrpkhk"
|
||||
/>
|
||||
</div>
|
||||
<div data-oid="q:lwf4n">
|
||||
<input
|
||||
type="text"
|
||||
name="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={handleInputChange}
|
||||
placeholder={t.contact.form.lastName}
|
||||
required
|
||||
className="w-full px-4 py-3 rounded-xl bg-white/20 border border-white/30 text-white placeholder-white/70 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all duration-200"
|
||||
data-oid="s6mgi:f"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email and Phone */}
|
||||
<div
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
data-oid="qlzghax"
|
||||
>
|
||||
<div data-oid="9eu.6rr">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
placeholder={t.contact.form.email}
|
||||
required
|
||||
className="w-full px-4 py-3 rounded-xl bg-white/20 border border-white/30 text-white placeholder-white/70 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all duration-200"
|
||||
data-oid="0fgza5i"
|
||||
/>
|
||||
</div>
|
||||
<div data-oid="d8--iet">
|
||||
<input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleInputChange}
|
||||
placeholder={t.contact.form.phone}
|
||||
className="w-full px-4 py-3 rounded-xl bg-white/20 border border-white/30 text-white placeholder-white/70 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all duration-200"
|
||||
data-oid="wwwvzhx"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div data-oid="59jnejx">
|
||||
<textarea
|
||||
name="message"
|
||||
value={formData.message}
|
||||
onChange={handleInputChange}
|
||||
placeholder={t.contact.form.message}
|
||||
required
|
||||
rows={6}
|
||||
className="w-full px-4 py-3 rounded-xl bg-white/20 border border-white/30 text-white placeholder-white/70 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all duration-200 resize-none"
|
||||
data-oid="u:fwgn0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div data-oid="rh_o0-0">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={`w-full py-4 px-8 rounded-xl font-semibold text-lg transition-all duration-200 transform hover:scale-105 ${
|
||||
isSubmitting
|
||||
? 'bg-gray-500 cursor-not-allowed'
|
||||
: submitStatus === 'success'
|
||||
? 'bg-green-500 hover:bg-green-600'
|
||||
: submitStatus === 'error'
|
||||
? 'bg-red-500 hover:bg-red-600'
|
||||
: 'bg-blue-500 hover:bg-blue-600'
|
||||
} text-white shadow-lg`}
|
||||
data-oid="l.p.45q"
|
||||
>
|
||||
{isSubmitting
|
||||
? 'Sending...'
|
||||
: submitStatus === 'success'
|
||||
? 'Message Sent!'
|
||||
: submitStatus === 'error'
|
||||
? 'Try Again'
|
||||
: t.contact.form.send}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="space-y-8" data-oid="gcnlszq">
|
||||
<div
|
||||
className="bg-white/10 backdrop-blur-md rounded-3xl p-8 border border-white/20 shadow-2xl"
|
||||
data-oid="dk_e_44"
|
||||
>
|
||||
<h3
|
||||
className="text-2xl font-bold text-white mb-6"
|
||||
data-oid="p5r03zc"
|
||||
>
|
||||
{t.contact.info.title}
|
||||
</h3>
|
||||
<p
|
||||
className="text-white/90 mb-8 leading-relaxed"
|
||||
data-oid="b3:lk4-"
|
||||
>
|
||||
{t.contact.info.description}
|
||||
</p>
|
||||
|
||||
<div className="space-y-6" data-oid="hl4u4je">
|
||||
{/* Email */}
|
||||
<div className="flex items-center space-x-4" data-oid="g4na330">
|
||||
<div
|
||||
className="w-12 h-12 bg-blue-500/20 rounded-full flex items-center justify-center"
|
||||
data-oid="kl7x59e"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6 text-blue-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
data-oid="runsvm_"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
data-oid="ccc37jv"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div data-oid="9kfzma8">
|
||||
<p className="text-white/70 text-sm" data-oid="vk.z7te">
|
||||
Email
|
||||
</p>
|
||||
<p
|
||||
className="text-white font-medium"
|
||||
data-oid="mk6ei1x"
|
||||
>
|
||||
{t.contact.info.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div className="flex items-center space-x-4" data-oid=".1a0g8l">
|
||||
<div
|
||||
className="w-12 h-12 bg-green-500/20 rounded-full flex items-center justify-center"
|
||||
data-oid="4m5q0ec"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6 text-green-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
data-oid="8amai42"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
data-oid="7oc0h_5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div data-oid="knv5:6o">
|
||||
<p className="text-white/70 text-sm" data-oid=".:vq7ax">
|
||||
Phone
|
||||
</p>
|
||||
<p
|
||||
className="text-white font-medium"
|
||||
data-oid="3wxvnf4"
|
||||
>
|
||||
{t.contact.info.phone}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="flex items-center space-x-4" data-oid="0is872f">
|
||||
<div
|
||||
className="w-12 h-12 bg-purple-500/20 rounded-full flex items-center justify-center"
|
||||
data-oid="s:hqv83"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6 text-purple-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
data-oid="svaynoq"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
data-oid="i1luz2w"
|
||||
/>
|
||||
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
data-oid="c0qo67."
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div data-oid="nf5h5sm">
|
||||
<p className="text-white/70 text-sm" data-oid="uramiy4">
|
||||
Address
|
||||
</p>
|
||||
<p
|
||||
className="text-white font-medium"
|
||||
data-oid="k56c61v"
|
||||
>
|
||||
{t.contact.info.address}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Info Card */}
|
||||
<div
|
||||
className="bg-gradient-to-br from-blue-500/20 to-purple-500/20 backdrop-blur-md rounded-3xl p-8 border border-white/20 shadow-2xl"
|
||||
data-oid="_.1cxld"
|
||||
>
|
||||
<h4
|
||||
className="text-xl font-bold text-white mb-4"
|
||||
data-oid="m83e9xn"
|
||||
>
|
||||
Response Time
|
||||
</h4>
|
||||
<p className="text-white/90 mb-4" data-oid="tjr_3e3">
|
||||
We typically respond within 24 hours during business days.
|
||||
</p>
|
||||
<div className="flex items-center space-x-2" data-oid="ooel29n">
|
||||
<div
|
||||
className="w-3 h-3 bg-green-400 rounded-full animate-pulse"
|
||||
data-oid="5l9ximw"
|
||||
></div>
|
||||
<span
|
||||
className="text-green-400 text-sm font-medium"
|
||||
data-oid="l6d:_3y"
|
||||
>
|
||||
Online Now
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer locale={params.locale} data-oid="t66xypj" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
app/[locale]/layout.tsx
Normal file
109
app/[locale]/layout.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import type { Metadata } from 'next';
|
||||
import '../globals.css';
|
||||
import { locales, Locale, defaultLocale } from '../../lib/i18n';
|
||||
import { getTranslations } from '../../lib/translations';
|
||||
import {
|
||||
generateAlternateLinks,
|
||||
generateCanonicalUrl,
|
||||
generateStructuredData,
|
||||
} from '../../lib/seo-utils';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { locale: Locale };
|
||||
}): Promise<Metadata> {
|
||||
const translations = getTranslations(params.locale);
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://your-domain.com';
|
||||
|
||||
const alternateLinks = generateAlternateLinks('', baseUrl);
|
||||
const canonicalUrl = generateCanonicalUrl('', params.locale, baseUrl);
|
||||
|
||||
return {
|
||||
title: {
|
||||
default: translations.meta.title,
|
||||
template: `%s | ${translations.meta.title}`,
|
||||
},
|
||||
description: translations.meta.description,
|
||||
keywords: translations.meta.keywords,
|
||||
|
||||
authors: [{ name: translations.meta.author }],
|
||||
creator: translations.meta.author,
|
||||
publisher: translations.meta.author,
|
||||
formatDetection: {
|
||||
email: false,
|
||||
address: false,
|
||||
telephone: false,
|
||||
},
|
||||
metadataBase: new URL(baseUrl),
|
||||
alternates: {
|
||||
canonical: canonicalUrl,
|
||||
languages: Object.fromEntries(alternateLinks.map((link) => [link.hrefLang, link.href])),
|
||||
},
|
||||
openGraph: {
|
||||
title: translations.meta.title,
|
||||
description: translations.meta.description,
|
||||
url: canonicalUrl,
|
||||
siteName: translations.meta.title,
|
||||
locale: params.locale,
|
||||
alternateLocale: locales.filter((loc) => loc !== params.locale),
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: `${baseUrl}/og-image.jpg`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: translations.meta.title,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: translations.meta.title,
|
||||
description: translations.meta.description,
|
||||
images: [`${baseUrl}/og-image.jpg`],
|
||||
creator: '@yourtwitterhandle',
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-video-preview': -1,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
verification: {
|
||||
google: 'your-google-verification-code',
|
||||
// Add other search engine verification codes as needed
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: { locale: Locale };
|
||||
}) {
|
||||
// Validate that the incoming `locale` parameter is valid
|
||||
if (!locales.includes(params.locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang={params.locale} data-oid="9-vbv6i">
|
||||
<body className="" data-oid="2snbkeg">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
210
app/[locale]/page.tsx
Normal file
210
app/[locale]/page.tsx
Normal file
@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import Navigation from '../../components/Navigation';
|
||||
import FloatingLanguageSwitcher from '../../components/FloatingLanguageSwitcher';
|
||||
import BlogShowcase from '../../components/BlogShowcase';
|
||||
import Footer from '../../components/Footer';
|
||||
import { Locale } from '../../lib/i18n';
|
||||
import { getTranslations } from '../../lib/translations';
|
||||
import { updateDocumentMeta } from '../../lib/seo-utils';
|
||||
|
||||
// Particle Background Component
|
||||
function ParticleBackground() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size
|
||||
const resizeCanvas = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
};
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
|
||||
// Particle system
|
||||
const particles: Array<{
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
size: number;
|
||||
opacity: number;
|
||||
}> = [];
|
||||
|
||||
// Create particles
|
||||
for (let i = 0; i < 100; i++) {
|
||||
particles.push({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
vx: (Math.random() - 0.5) * 0.5,
|
||||
vy: (Math.random() - 0.5) * 0.5,
|
||||
size: Math.random() * 3 + 1,
|
||||
opacity: Math.random() * 0.5 + 0.2,
|
||||
});
|
||||
}
|
||||
|
||||
// Animation loop
|
||||
const animate = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
particles.forEach((particle) => {
|
||||
// Update position
|
||||
particle.x += particle.vx;
|
||||
particle.y += particle.vy;
|
||||
|
||||
// Wrap around edges
|
||||
if (particle.x < 0) particle.x = canvas.width;
|
||||
if (particle.x > canvas.width) particle.x = 0;
|
||||
if (particle.y < 0) particle.y = canvas.height;
|
||||
if (particle.y > canvas.height) particle.y = 0;
|
||||
|
||||
// Draw particle
|
||||
ctx.beginPath();
|
||||
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${particle.opacity})`;
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
// Draw connections
|
||||
particles.forEach((particle, i) => {
|
||||
particles.slice(i + 1).forEach((otherParticle) => {
|
||||
const dx = particle.x - otherParticle.x;
|
||||
const dy = particle.y - otherParticle.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < 100) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(particle.x, particle.y);
|
||||
ctx.lineTo(otherParticle.x, otherParticle.y);
|
||||
ctx.strokeStyle = `rgba(255, 255, 255, ${0.1 * (1 - distance / 100)})`;
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resizeCanvas);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
data-oid="iiiegs:"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page({ params }: { params: { locale: Locale } }) {
|
||||
const t = getTranslations(params.locale);
|
||||
|
||||
// Update meta tags when locale changes
|
||||
useEffect(() => {
|
||||
updateDocumentMeta(params.locale);
|
||||
}, [params.locale]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative overflow-hidden" data-oid="krtdxh9">
|
||||
{/* Background Image with Overlay */}
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.6)), url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 800"><defs><linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:%23d4a574;stop-opacity:1" /><stop offset="50%" style="stop-color:%23b8956a;stop-opacity:1" /><stop offset="100%" style="stop-color:%23a08660;stop-opacity:1" /></linearGradient></defs><rect width="1200" height="800" fill="url(%23grad1)"/><path d="M0,400 Q300,200 600,350 T1200,300 L1200,800 L0,800 Z" fill="%23967d56"/><path d="M0,500 Q400,300 800,450 T1200,400 L1200,800 L0,800 Z" fill="%238a7350"/><circle cx="200" cy="150" r="80" fill="%23f4e4c1" opacity="0.3"/><circle cx="1000" cy="200" r="60" fill="%23f4e4c1" opacity="0.2"/></svg>')`,
|
||||
}}
|
||||
data-oid="t35j3uz"
|
||||
/>
|
||||
|
||||
{/* Particle Background */}
|
||||
<ParticleBackground data-oid="1eq47iz" />
|
||||
|
||||
{/* Navigation */}
|
||||
<Navigation locale={params.locale} data-oid=".-cxh3." />
|
||||
|
||||
{/* Floating Language Switcher */}
|
||||
<FloatingLanguageSwitcher locale={params.locale} data-oid="usi6f7v" />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section
|
||||
id="hero"
|
||||
className="relative z-10 flex items-center justify-center min-h-screen text-center px-4"
|
||||
data-oid="2n7qgj-"
|
||||
>
|
||||
<div className="max-w-4xl mx-auto" data-oid="qdxxu5.">
|
||||
<h1
|
||||
className="text-5xl md:text-7xl font-bold text-white mb-8 leading-tight"
|
||||
data-oid="pf:lgs4"
|
||||
>
|
||||
{t.hero.title}
|
||||
</h1>
|
||||
<p
|
||||
className="text-xl md:text-2xl text-white/90 mb-12 max-w-3xl mx-auto leading-relaxed"
|
||||
data-oid="vm1tz7h"
|
||||
>
|
||||
{t.hero.subtitle}
|
||||
</p>
|
||||
<div
|
||||
className="flex flex-col sm:flex-row gap-6 justify-center"
|
||||
data-oid="mbx0eri"
|
||||
>
|
||||
<button
|
||||
className="bg-green-500 hover:bg-green-600 text-white px-8 py-4 rounded-full font-semibold text-lg transform hover:scale-105 transition-all duration-200 shadow-lg"
|
||||
data-oid="e2cv0mq"
|
||||
>
|
||||
{t.hero.startJourney}
|
||||
</button>
|
||||
<button
|
||||
className="border-2 border-white text-white hover:bg-white hover:text-gray-900 px-8 py-4 rounded-full font-semibold text-lg transform hover:scale-105 transition-all duration-200"
|
||||
data-oid="sv:-vjb"
|
||||
>
|
||||
{t.hero.learnMore}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Blog Showcase Section */}
|
||||
<BlogShowcase locale={params.locale} data-oid=":qdpiz:" />
|
||||
|
||||
{/* Floating Action Elements */}
|
||||
<div className="fixed bottom-8 left-8 z-50" data-oid="f2x6cuz">
|
||||
<button
|
||||
className="bg-green-500 hover:bg-green-600 text-white p-4 rounded-full shadow-lg transform hover:scale-110 transition-all duration-200"
|
||||
data-oid="jye6ey7"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
data-oid="ggzwwl-"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
||||
data-oid="cngsmmd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer locale={params.locale} data-oid="_rnutw_" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
700
app/[locale]/products/page.tsx
Normal file
700
app/[locale]/products/page.tsx
Normal file
@ -0,0 +1,700 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import Navigation from '../../../components/Navigation';
|
||||
import FloatingLanguageSwitcher from '../../../components/FloatingLanguageSwitcher';
|
||||
import Footer from '../../../components/Footer';
|
||||
import { Locale } from '../../../lib/i18n';
|
||||
import { getTranslations } from '../../../lib/translations';
|
||||
import { updateDocumentMeta } from '../../../lib/seo-utils';
|
||||
|
||||
// Enhanced Particle Background Component for Products
|
||||
function ProductParticleBackground() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size
|
||||
const resizeCanvas = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
};
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', resizeCanvas);
|
||||
|
||||
// Enhanced particle system with different types
|
||||
const particles: Array<{
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
size: number;
|
||||
opacity: number;
|
||||
type: 'circle' | 'square' | 'triangle';
|
||||
color: string;
|
||||
rotation: number;
|
||||
rotationSpeed: number;
|
||||
}> = [];
|
||||
|
||||
const colors = ['#22c55e', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6'];
|
||||
|
||||
// Create particles
|
||||
for (let i = 0; i < 80; i++) {
|
||||
particles.push({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
vx: (Math.random() - 0.5) * 0.8,
|
||||
vy: (Math.random() - 0.5) * 0.8,
|
||||
size: Math.random() * 4 + 2,
|
||||
opacity: Math.random() * 0.6 + 0.2,
|
||||
type: ['circle', 'square', 'triangle'][Math.floor(Math.random() * 3)] as
|
||||
| 'circle'
|
||||
| 'square'
|
||||
| 'triangle',
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
rotation: Math.random() * Math.PI * 2,
|
||||
rotationSpeed: (Math.random() - 0.5) * 0.02,
|
||||
});
|
||||
}
|
||||
|
||||
// Animation loop
|
||||
const animate = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
particles.forEach((particle) => {
|
||||
// Update position
|
||||
particle.x += particle.vx;
|
||||
particle.y += particle.vy;
|
||||
particle.rotation += particle.rotationSpeed;
|
||||
|
||||
// Wrap around edges
|
||||
if (particle.x < -particle.size) particle.x = canvas.width + particle.size;
|
||||
if (particle.x > canvas.width + particle.size) particle.x = -particle.size;
|
||||
if (particle.y < -particle.size) particle.y = canvas.height + particle.size;
|
||||
if (particle.y > canvas.height + particle.size) particle.y = -particle.size;
|
||||
|
||||
// Set style
|
||||
ctx.fillStyle = particle.color;
|
||||
ctx.globalAlpha = particle.opacity;
|
||||
ctx.save();
|
||||
ctx.translate(particle.x, particle.y);
|
||||
ctx.rotate(particle.rotation);
|
||||
|
||||
// Draw different shapes
|
||||
ctx.beginPath();
|
||||
switch (particle.type) {
|
||||
case 'circle':
|
||||
ctx.arc(0, 0, particle.size, 0, Math.PI * 2);
|
||||
break;
|
||||
case 'square':
|
||||
ctx.rect(
|
||||
-particle.size,
|
||||
-particle.size,
|
||||
particle.size * 2,
|
||||
particle.size * 2,
|
||||
);
|
||||
break;
|
||||
case 'triangle':
|
||||
ctx.moveTo(0, -particle.size);
|
||||
ctx.lineTo(-particle.size, particle.size);
|
||||
ctx.lineTo(particle.size, particle.size);
|
||||
ctx.closePath();
|
||||
break;
|
||||
}
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
});
|
||||
|
||||
// Draw connections with different colors
|
||||
particles.forEach((particle, i) => {
|
||||
particles.slice(i + 1).forEach((otherParticle) => {
|
||||
const dx = particle.x - otherParticle.x;
|
||||
const dy = particle.y - otherParticle.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < 120) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(particle.x, particle.y);
|
||||
ctx.lineTo(otherParticle.x, otherParticle.y);
|
||||
const gradient = ctx.createLinearGradient(
|
||||
particle.x,
|
||||
particle.y,
|
||||
otherParticle.x,
|
||||
otherParticle.y,
|
||||
);
|
||||
gradient.addColorStop(0, particle.color);
|
||||
gradient.addColorStop(1, otherParticle.color);
|
||||
ctx.strokeStyle = gradient;
|
||||
ctx.globalAlpha = 0.1 * (1 - distance / 120);
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animate();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resizeCanvas);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
data-oid="j7rncr6"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductsPage({ params }: { params: { locale: Locale } }) {
|
||||
const t = getTranslations(params.locale);
|
||||
|
||||
// Update meta tags when locale changes
|
||||
useEffect(() => {
|
||||
updateDocumentMeta(params.locale);
|
||||
}, [params.locale]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen relative overflow-hidden bg-gradient-to-br from-slate-900 via-blue-900 to-indigo-900"
|
||||
data-oid="cvpywwb"
|
||||
>
|
||||
{/* Enhanced Particle Background */}
|
||||
<ProductParticleBackground data-oid="uc9k0a8" />
|
||||
|
||||
{/* Navigation */}
|
||||
<Navigation locale={params.locale} data-oid="531iitl" />
|
||||
|
||||
{/* Floating Language Switcher */}
|
||||
<FloatingLanguageSwitcher locale={params.locale} data-oid="kpdg95h" />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative z-10 pt-20 pb-16 px-4" data-oid="lm2ytxy">
|
||||
<div className="max-w-7xl mx-auto text-center" data-oid="j5e12u3">
|
||||
<h1
|
||||
className="text-5xl md:text-7xl font-bold text-white mb-6 leading-tight"
|
||||
data-oid="5baiugk"
|
||||
>
|
||||
{t.products?.title || 'Our Products'}
|
||||
</h1>
|
||||
<p
|
||||
className="text-xl md:text-2xl text-white/80 mb-12 max-w-3xl mx-auto"
|
||||
data-oid=":evsigy"
|
||||
>
|
||||
{t.products?.subtitle ||
|
||||
'Discover innovative solutions for a sustainable future'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured Product */}
|
||||
<section className="relative z-10 py-16 px-4" data-oid="3djucnf">
|
||||
<div className="max-w-7xl mx-auto" data-oid="jffsbbq">
|
||||
<div
|
||||
className="bg-white/10 backdrop-blur-md rounded-3xl p-8 md:p-12 border border-white/20"
|
||||
data-oid="5j0vqo-"
|
||||
>
|
||||
<div
|
||||
className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center"
|
||||
data-oid="w:qoogs"
|
||||
>
|
||||
<div data-oid="jp:93sh">
|
||||
<div
|
||||
className="inline-block bg-green-500 text-white px-4 py-2 rounded-full text-sm font-medium mb-6"
|
||||
data-oid="_2jwbsf"
|
||||
>
|
||||
Featured Product
|
||||
</div>
|
||||
<h2
|
||||
className="text-4xl md:text-5xl font-bold text-white mb-6"
|
||||
data-oid="42kynsq"
|
||||
>
|
||||
{t.products?.featured?.title || 'Eco-Life Simulator'}
|
||||
</h2>
|
||||
<p className="text-xl text-white/80 mb-8" data-oid=":6k38y3">
|
||||
{t.products?.featured?.description ||
|
||||
'Experience the ultimate environmental simulation game'}
|
||||
</p>
|
||||
<div className="space-y-4 mb-8" data-oid="gakgmck">
|
||||
{(t.products?.featured?.features || []).map(
|
||||
(feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center space-x-3"
|
||||
data-oid="f4az8h0"
|
||||
>
|
||||
<div
|
||||
className="w-6 h-6 bg-green-500 rounded-full flex items-center justify-center"
|
||||
data-oid="nwia:z7"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
data-oid="to48jui"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
data-oid="hqp78tt"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-white/90" data-oid="a.d5.o0">
|
||||
{feature}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-6" data-oid="ciy3l:k">
|
||||
<span
|
||||
className="text-3xl font-bold text-green-400"
|
||||
data-oid="j3m:k.g"
|
||||
>
|
||||
{t.products?.featured?.price || '$29.99'}
|
||||
</span>
|
||||
<button
|
||||
className="bg-green-500 hover:bg-green-600 text-white px-8 py-4 rounded-full font-semibold text-lg transform hover:scale-105 transition-all duration-200 shadow-lg"
|
||||
data-oid="3vyrpk7"
|
||||
>
|
||||
{t.products?.featured?.cta || 'Get Started Now'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative" data-oid="2frqox_">
|
||||
<div
|
||||
className="aspect-square bg-gradient-to-br from-green-400 to-blue-500 rounded-3xl p-8 relative overflow-hidden"
|
||||
data-oid="sv04vkp"
|
||||
>
|
||||
{/* Product mockup with particles */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/20 rounded-3xl"
|
||||
data-oid="7mphq74"
|
||||
></div>
|
||||
<div
|
||||
className="relative z-10 h-full flex items-center justify-center"
|
||||
data-oid="::y3wwb"
|
||||
>
|
||||
<div className="text-center text-white" data-oid="_a3bv_z">
|
||||
<div
|
||||
className="w-24 h-24 bg-white/20 rounded-full mx-auto mb-4 flex items-center justify-center"
|
||||
data-oid="xoz1gv-"
|
||||
>
|
||||
<svg
|
||||
className="w-12 h-12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
data-oid="htmw:.5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
data-oid="qm_gi7x"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3
|
||||
className="text-2xl font-bold mb-2"
|
||||
data-oid="._-sk9i"
|
||||
>
|
||||
Interactive Demo
|
||||
</h3>
|
||||
<p className="text-white/80" data-oid="_yx_77f">
|
||||
Experience the simulation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Product Categories */}
|
||||
<section className="relative z-10 py-16 px-4" data-oid="dth.vfh">
|
||||
<div className="max-w-7xl mx-auto" data-oid="b_yrju2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8" data-oid="deq5x:y">
|
||||
<div
|
||||
className="bg-white/10 backdrop-blur-md rounded-2xl p-8 border border-white/20 hover:bg-white/15 transition-all duration-300"
|
||||
data-oid="vrhxdx5"
|
||||
>
|
||||
<div
|
||||
className="w-16 h-16 bg-green-500 rounded-2xl flex items-center justify-center mb-6"
|
||||
data-oid="fbph6.s"
|
||||
>
|
||||
<svg
|
||||
className="w-8 h-8 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
data-oid="i-5t_6a"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1a3 3 0 000-6h-1m0 6V4m0 6h6m-7 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
data-oid="21l8sg_"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-white mb-4" data-oid="zh43ndf">
|
||||
{t.products.categories.games.title}
|
||||
</h3>
|
||||
<p className="text-white/70" data-oid=".xj-6y.">
|
||||
{t.products.categories.games.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-white/10 backdrop-blur-md rounded-2xl p-8 border border-white/20 hover:bg-white/15 transition-all duration-300"
|
||||
data-oid="_r4xti3"
|
||||
>
|
||||
<div
|
||||
className="w-16 h-16 bg-blue-500 rounded-2xl flex items-center justify-center mb-6"
|
||||
data-oid="2xc--or"
|
||||
>
|
||||
<svg
|
||||
className="w-8 h-8 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
data-oid="xr0zrf:"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
data-oid="e6ly5m_"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-white mb-4" data-oid="7vi9m0h">
|
||||
{t.products.categories.tools.title}
|
||||
</h3>
|
||||
<p className="text-white/70" data-oid="f1:5c.n">
|
||||
{t.products.categories.tools.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-white/10 backdrop-blur-md rounded-2xl p-8 border border-white/20 hover:bg-white/15 transition-all duration-300"
|
||||
data-oid="tc7x7dh"
|
||||
>
|
||||
<div
|
||||
className="w-16 h-16 bg-purple-500 rounded-2xl flex items-center justify-center mb-6"
|
||||
data-oid="q2r2se9"
|
||||
>
|
||||
<svg
|
||||
className="w-8 h-8 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
data-oid="ahfdmqs"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
data-oid="w05eav4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-white mb-4" data-oid="b8tkf9z">
|
||||
{t.products.categories.education.title}
|
||||
</h3>
|
||||
<p className="text-white/70" data-oid="ac0dw_k">
|
||||
{t.products.categories.education.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Product Grid */}
|
||||
<section className="relative z-10 py-16 px-4" data-oid=":df3vby">
|
||||
<div className="max-w-7xl mx-auto" data-oid="1fm-ile">
|
||||
<div
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
|
||||
data-oid="mq9zk93"
|
||||
>
|
||||
{/* Product 1 */}
|
||||
<div
|
||||
className="bg-white/10 backdrop-blur-md rounded-2xl p-6 border border-white/20 hover:bg-white/15 transition-all duration-300 group"
|
||||
data-oid="4m47ygl"
|
||||
>
|
||||
<div
|
||||
className="aspect-square bg-gradient-to-br from-green-400 to-green-600 rounded-xl mb-6 relative overflow-hidden"
|
||||
data-oid="ni7-0vh"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/20"
|
||||
data-oid="qqzr2r2"
|
||||
></div>
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
data-oid="kg2s6ba"
|
||||
>
|
||||
<svg
|
||||
className="w-12 h-12 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
data-oid=".exeex5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
data-oid="6vhjg1_"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-2" data-oid="27_ijij">
|
||||
{t.products.productList.ecoSimulator.title}
|
||||
</h3>
|
||||
<p className="text-white/70 mb-4 text-sm" data-oid="5x:wp2_">
|
||||
{t.products.productList.ecoSimulator.description}
|
||||
</p>
|
||||
<div className="flex items-center justify-between" data-oid="ad00jbx">
|
||||
<span className="text-green-400 font-bold" data-oid="6q1_97d">
|
||||
{t.products.productList.ecoSimulator.price}
|
||||
</span>
|
||||
<button
|
||||
className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200"
|
||||
data-oid="e.r9qu-"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product 2 */}
|
||||
<div
|
||||
className="bg-white/10 backdrop-blur-md rounded-2xl p-6 border border-white/20 hover:bg-white/15 transition-all duration-300 group"
|
||||
data-oid="5rbr4p8"
|
||||
>
|
||||
<div
|
||||
className="aspect-square bg-gradient-to-br from-blue-400 to-blue-600 rounded-xl mb-6 relative overflow-hidden"
|
||||
data-oid="m1w2yx2"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/20"
|
||||
data-oid="w8uva_n"
|
||||
></div>
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
data-oid="igj8g:d"
|
||||
>
|
||||
<svg
|
||||
className="w-12 h-12 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
data-oid="zsd1xns"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
data-oid="c7izslw"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-2" data-oid="16315pd">
|
||||
{t.products.productList.carbonTracker.title}
|
||||
</h3>
|
||||
<p className="text-white/70 mb-4 text-sm" data-oid="fxx3xzs">
|
||||
{t.products.productList.carbonTracker.description}
|
||||
</p>
|
||||
<div className="flex items-center justify-between" data-oid="3iqh5.-">
|
||||
<span className="text-blue-400 font-bold" data-oid="afep:s-">
|
||||
{t.products.productList.carbonTracker.price}
|
||||
</span>
|
||||
<button
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200"
|
||||
data-oid="v:84d.f"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product 3 */}
|
||||
<div
|
||||
className="bg-white/10 backdrop-blur-md rounded-2xl p-6 border border-white/20 hover:bg-white/15 transition-all duration-300 group"
|
||||
data-oid="ylps7.f"
|
||||
>
|
||||
<div
|
||||
className="aspect-square bg-gradient-to-br from-purple-400 to-purple-600 rounded-xl mb-6 relative overflow-hidden"
|
||||
data-oid="sgmy4a-"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/20"
|
||||
data-oid="1jrvfuf"
|
||||
></div>
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
data-oid="giashnw"
|
||||
>
|
||||
<svg
|
||||
className="w-12 h-12 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
data-oid=":haulu-"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
data-oid="gg2vvsi"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-2" data-oid="6itbonk">
|
||||
{t.products.productList.greenGuide.title}
|
||||
</h3>
|
||||
<p className="text-white/70 mb-4 text-sm" data-oid="94_u:5r">
|
||||
{t.products.productList.greenGuide.description}
|
||||
</p>
|
||||
<div className="flex items-center justify-between" data-oid="im08pp2">
|
||||
<span className="text-purple-400 font-bold" data-oid="hxtwl9f">
|
||||
{t.products.productList.greenGuide.price}
|
||||
</span>
|
||||
<button
|
||||
className="bg-purple-500 hover:bg-purple-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200"
|
||||
data-oid="3e9u9t0"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product 4 */}
|
||||
<div
|
||||
className="bg-white/10 backdrop-blur-md rounded-2xl p-6 border border-white/20 hover:bg-white/15 transition-all duration-300 group"
|
||||
data-oid="od:zoot"
|
||||
>
|
||||
<div
|
||||
className="aspect-square bg-gradient-to-br from-orange-400 to-orange-600 rounded-xl mb-6 relative overflow-hidden"
|
||||
data-oid=".09wskk"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-black/20"
|
||||
data-oid="xb_lxfj"
|
||||
></div>
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
data-oid="gbvwymw"
|
||||
>
|
||||
<svg
|
||||
className="w-12 h-12 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
data-oid="1m6hjyl"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"
|
||||
data-oid=".sikv1l"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-2" data-oid="gfqu2:k">
|
||||
{t.products.productList.ecoChallenge.title}
|
||||
</h3>
|
||||
<p className="text-white/70 mb-4 text-sm" data-oid="rrsnl8c">
|
||||
{t.products.productList.ecoChallenge.description}
|
||||
</p>
|
||||
<div className="flex items-center justify-between" data-oid="s5jbagy">
|
||||
<span className="text-orange-400 font-bold" data-oid="rmdip20">
|
||||
{t.products.productList.ecoChallenge.price}
|
||||
</span>
|
||||
<button
|
||||
className="bg-orange-500 hover:bg-orange-600 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200"
|
||||
data-oid="gjhr-2m"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="relative z-10 py-20 px-4" data-oid="9d:srtj">
|
||||
<div className="max-w-4xl mx-auto text-center" data-oid="ehinuqy">
|
||||
<div
|
||||
className="bg-white/10 backdrop-blur-md rounded-3xl p-12 border border-white/20"
|
||||
data-oid="bszxslt"
|
||||
>
|
||||
<h2
|
||||
className="text-4xl md:text-5xl font-bold text-white mb-6"
|
||||
data-oid="y3mxv4e"
|
||||
>
|
||||
Ready to Make a Difference?
|
||||
</h2>
|
||||
<p className="text-xl text-white/80 mb-8" data-oid="4rdacti">
|
||||
Join thousands of users already making sustainable choices with our
|
||||
products.
|
||||
</p>
|
||||
<div
|
||||
className="flex flex-col sm:flex-row gap-4 justify-center"
|
||||
data-oid="d-iudla"
|
||||
>
|
||||
<button
|
||||
className="bg-green-500 hover:bg-green-600 text-white px-8 py-4 rounded-full font-semibold text-lg transform hover:scale-105 transition-all duration-200 shadow-lg"
|
||||
data-oid="wn7ixwn"
|
||||
>
|
||||
Explore All Products
|
||||
</button>
|
||||
<button
|
||||
className="border-2 border-white text-white hover:bg-white hover:text-gray-900 px-8 py-4 rounded-full font-semibold text-lg transform hover:scale-105 transition-all duration-200"
|
||||
data-oid="1iho.r8"
|
||||
>
|
||||
Contact Sales
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer locale={params.locale} data-oid="0vh4cx:" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
132
app/globals.css
Normal file
132
app/globals.css
Normal file
@ -0,0 +1,132 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities; /* Line clamp utilities */
|
||||
@layer utilities {
|
||||
.line-clamp-1 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
.line-clamp-2 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
.line-clamp-3 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
.line-clamp-4 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
}
|
||||
} /* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(34, 197, 94, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(34, 197, 94, 0.7);
|
||||
} /* Smooth animations */
|
||||
* {
|
||||
scroll-behavior: smooth;
|
||||
} /* Focus styles for accessibility */
|
||||
button:focus,
|
||||
a:focus,
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
outline: 2px solid #22c55e;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
}
|
||||
}
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
16
app/layout.tsx
Normal file
16
app/layout.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
export const metadata: Metadata = {
|
||||
title: 'Eco-Life - Rebuild Earth, Starting With Your Choices',
|
||||
description:
|
||||
'Join the movement to create a sustainable future. Every choice you make today shapes the world of tomorrow.',
|
||||
};
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="en" data-oid="ki63-os">
|
||||
<body className="" data-oid="i_q8qsb">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
44
app/page.tsx
Normal file
44
app/page.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { locales, defaultLocale } from '../lib/i18n';
|
||||
|
||||
export default function RootPage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// Get user's preferred language from the browser
|
||||
const userLang = navigator.language;
|
||||
|
||||
// Find a matching supported language
|
||||
let targetLang = defaultLocale;
|
||||
const matchedFullLang = locales.find((lang) => lang === userLang);
|
||||
if (matchedFullLang) {
|
||||
targetLang = matchedFullLang;
|
||||
} else {
|
||||
const baseLang = userLang.split('-')[0];
|
||||
const matchedBaseLang = locales.find((lang) => lang.startsWith(baseLang));
|
||||
if (matchedBaseLang) {
|
||||
targetLang = matchedBaseLang;
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect to the appropriate language-specific page
|
||||
router.replace(`/${targetLang}`);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
backgroundColor: '#111827',
|
||||
color: 'white',
|
||||
fontFamily: 'sans-serif'
|
||||
}}>
|
||||
<p>Redirecting to your preferred language...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
app/robots.ts
Normal file
22
app/robots.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://your-domain.com';
|
||||
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: ['/api/', '/_next/', '/admin/', '*.json'],
|
||||
},
|
||||
{
|
||||
userAgent: 'Googlebot',
|
||||
allow: '/',
|
||||
disallow: ['/api/', '/_next/', '/admin/'],
|
||||
},
|
||||
],
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
host: baseUrl,
|
||||
};
|
||||
}
|
||||
16
app/sitemap.ts
Normal file
16
app/sitemap.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
import { generateSitemap } from '../lib/sitemap-generator';
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://your-domain.com';
|
||||
|
||||
const sitemapEntries = generateSitemap(baseUrl);
|
||||
|
||||
return sitemapEntries.map((entry) => ({
|
||||
url: entry.url,
|
||||
lastModified: entry.lastModified,
|
||||
changeFrequency: entry.changeFrequency,
|
||||
priority: entry.priority,
|
||||
alternates: entry.alternates,
|
||||
}));
|
||||
}
|
||||
20
components.json
Normal file
20
components.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
287
components/BlogShowcase.tsx
Normal file
287
components/BlogShowcase.tsx
Normal file
@ -0,0 +1,287 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Locale } from '../lib/i18n';
|
||||
import { getTranslations } from '../lib/translations';
|
||||
|
||||
interface BlogShowcaseProps {
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export default function BlogShowcase({ locale }: BlogShowcaseProps) {
|
||||
const t = getTranslations(locale);
|
||||
const [hoveredPost, setHoveredPost] = useState<string | null>(null);
|
||||
|
||||
// 获取博客文章数据
|
||||
const blogPosts = [
|
||||
{
|
||||
id: 'featured',
|
||||
...t.blog.posts.featured,
|
||||
image: 'featured',
|
||||
},
|
||||
{
|
||||
id: 'post1',
|
||||
...t.blog.posts.post1,
|
||||
image: 'post1',
|
||||
},
|
||||
{
|
||||
id: 'post2',
|
||||
...t.blog.posts.post2,
|
||||
image: 'post2',
|
||||
},
|
||||
{
|
||||
id: 'post3',
|
||||
...t.blog.posts.post3,
|
||||
image: 'post3',
|
||||
},
|
||||
];
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case 'playerStories':
|
||||
return 'bg-amber-500';
|
||||
case 'development':
|
||||
return 'bg-green-500';
|
||||
case 'ecoFacts':
|
||||
return 'bg-emerald-500';
|
||||
default:
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryName = (category: string) => {
|
||||
switch (category) {
|
||||
case 'playerStories':
|
||||
return t.blog.categories.playerStories;
|
||||
case 'development':
|
||||
return t.blog.categories.development;
|
||||
case 'ecoFacts':
|
||||
return t.blog.categories.ecoFacts;
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
};
|
||||
|
||||
const getImageGradient = (imageId: string) => {
|
||||
const gradients = {
|
||||
featured: 'from-purple-500 via-blue-500 to-indigo-600',
|
||||
post1: 'from-amber-500 via-orange-500 to-red-500',
|
||||
post2: 'from-cyan-500 via-blue-500 to-indigo-500',
|
||||
post3: 'from-green-500 via-emerald-500 to-teal-500',
|
||||
};
|
||||
return gradients[imageId as keyof typeof gradients] || 'from-gray-500 to-gray-600';
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative z-10 py-20 bg-gradient-to-br from-slate-800 via-slate-900 to-black">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Section Header */}
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-white mb-4">
|
||||
{t.blog.title}
|
||||
</h2>
|
||||
<p className="text-xl text-white/80 max-w-3xl mx-auto leading-relaxed">
|
||||
{t.blog.subtitle}
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<Link
|
||||
href={`/${locale}/blog`}
|
||||
className="inline-flex items-center bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 text-white px-8 py-3 rounded-full font-semibold transition-all duration-200 transform hover:scale-105 shadow-lg"
|
||||
>
|
||||
{t.blog.categories.all}
|
||||
<svg
|
||||
className="w-5 h-5 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blog Posts Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Featured Post */}
|
||||
<div className="lg:row-span-2">
|
||||
<Link href={`/${locale}/blog/${blogPosts[0].id}`}>
|
||||
<article
|
||||
className="group cursor-pointer h-full"
|
||||
onMouseEnter={() => setHoveredPost(blogPosts[0].id)}
|
||||
onMouseLeave={() => setHoveredPost(null)}
|
||||
>
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-3xl overflow-hidden border border-white/20 shadow-2xl hover:shadow-3xl transition-all duration-500 transform hover:scale-[1.02] h-full">
|
||||
{/* Featured Post Image */}
|
||||
<div className="relative h-80 overflow-hidden">
|
||||
<div
|
||||
className={`absolute inset-0 bg-gradient-to-br ${getImageGradient(blogPosts[0].image)} opacity-90`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/20"></div>
|
||||
{/* Animated particles for featured post */}
|
||||
<div className="absolute inset-0">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`absolute w-2 h-2 bg-white/30 rounded-full animate-pulse ${
|
||||
hoveredPost === blogPosts[0].id
|
||||
? 'animate-bounce'
|
||||
: ''
|
||||
}`}
|
||||
style={{
|
||||
left: `${20 + i * 15}%`,
|
||||
top: `${30 + (i % 2) * 20}%`,
|
||||
animationDelay: `${i * 0.2}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-6 left-6">
|
||||
<span
|
||||
className={`${getCategoryColor(blogPosts[0].category)} text-white px-4 py-2 rounded-full text-sm font-medium shadow-lg`}
|
||||
>
|
||||
{getCategoryName(blogPosts[0].category)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute top-6 right-6">
|
||||
<div className="bg-white/20 backdrop-blur-sm rounded-full px-3 py-1">
|
||||
<span className="text-white text-sm font-medium">
|
||||
{t.blog.featured}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-6 left-6 right-6">
|
||||
<div className="text-white/90 text-sm mb-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span>{blogPosts[0].author}</span>
|
||||
<span>•</span>
|
||||
<span>{blogPosts[0].date}</span>
|
||||
<span>•</span>
|
||||
<span>{blogPosts[0].readTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Featured Post Content */}
|
||||
<div className="p-8">
|
||||
<h3 className="text-2xl font-bold text-white mb-4 group-hover:text-green-300 transition-colors duration-200 leading-tight">
|
||||
{blogPosts[0].title}
|
||||
</h3>
|
||||
<p className="text-white/80 mb-6 leading-relaxed line-clamp-3">
|
||||
{blogPosts[0].excerpt}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-green-300 font-medium group-hover:text-green-200 transition-colors duration-200">
|
||||
{t.blog.readMore} →
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Other Posts */}
|
||||
<div className="space-y-8">
|
||||
{blogPosts.slice(1).map((post, index) => (
|
||||
<Link key={post.id} href={`/${locale}/blog/${post.id}`}>
|
||||
<article
|
||||
className="group cursor-pointer"
|
||||
onMouseEnter={() => setHoveredPost(post.id)}
|
||||
onMouseLeave={() => setHoveredPost(null)}
|
||||
>
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl overflow-hidden border border-white/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:scale-[1.02]">
|
||||
<div className="flex">
|
||||
{/* Post Image */}
|
||||
<div className="relative w-32 h-32 flex-shrink-0 overflow-hidden">
|
||||
<div
|
||||
className={`absolute inset-0 bg-gradient-to-br ${getImageGradient(post.image)} opacity-90`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/20"></div>
|
||||
{/* Mini particles */}
|
||||
<div className="absolute inset-0">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`absolute w-1 h-1 bg-white/40 rounded-full ${
|
||||
hoveredPost === post.id
|
||||
? 'animate-ping'
|
||||
: 'animate-pulse'
|
||||
}`}
|
||||
style={{
|
||||
left: `${30 + i * 20}%`,
|
||||
top: `${40 + i * 10}%`,
|
||||
animationDelay: `${i * 0.3}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-2 left-2">
|
||||
<span
|
||||
className={`${getCategoryColor(post.category)} text-white px-2 py-1 rounded-full text-xs font-medium`}
|
||||
>
|
||||
{getCategoryName(post.category)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Post Content */}
|
||||
<div className="flex-1 p-6">
|
||||
<h4 className="text-lg font-bold text-white mb-2 group-hover:text-green-300 transition-colors duration-200 line-clamp-2">
|
||||
{post.title}
|
||||
</h4>
|
||||
<p className="text-white/70 text-sm mb-3 line-clamp-2">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-white/60 text-xs">
|
||||
<span>{post.author}</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span>{post.readTime}</span>
|
||||
</div>
|
||||
<span className="text-green-300 text-sm font-medium group-hover:text-green-200 transition-colors duration-200">
|
||||
→
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom CTA */}
|
||||
<div className="text-center mt-16">
|
||||
<div className="bg-gradient-to-r from-green-500/20 to-emerald-500/20 backdrop-blur-md rounded-2xl p-8 border border-green-500/30">
|
||||
<h3 className="text-2xl font-bold text-white mb-4">{t.blog.stayUpdated}</h3>
|
||||
<p className="text-white/80 mb-6 max-w-2xl mx-auto">
|
||||
{t.blog.stayUpdatedDesc}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href={`/${locale}/blog`}
|
||||
className="bg-green-500 hover:bg-green-600 text-white px-8 py-3 rounded-full font-semibold transition-all duration-200 transform hover:scale-105"
|
||||
>
|
||||
{t.blog.exploreAll}
|
||||
</Link>
|
||||
<button className="border-2 border-green-500 text-green-400 hover:bg-green-500 hover:text-white px-8 py-3 rounded-full font-semibold transition-all duration-200">
|
||||
{t.blog.subscribe}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
271
components/FloatingLanguageSwitcher.tsx
Normal file
271
components/FloatingLanguageSwitcher.tsx
Normal file
@ -0,0 +1,271 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { Locale, locales, localeNames, localeFlags } from '../lib/i18n';
|
||||
|
||||
interface FloatingLanguageSwitcherProps {
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export default function FloatingLanguageSwitcher({ locale }: FloatingLanguageSwitcherProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const switcherRef = useRef<HTMLDivElement>(null);
|
||||
const lastScrollY = useRef(0);
|
||||
|
||||
// Ensure component is mounted before rendering to avoid hydration issues
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
// Handle scroll to show/hide the switcher (temporarily disabled for visibility)
|
||||
useEffect(() => {
|
||||
// Keep the switcher always visible for now
|
||||
setIsVisible(true);
|
||||
|
||||
// Uncomment below to re-enable scroll hiding
|
||||
/*
|
||||
const handleScroll = () => {
|
||||
const currentScrollY = window.scrollY;
|
||||
if (currentScrollY > lastScrollY.current && currentScrollY > 100) {
|
||||
// Scrolling down
|
||||
setIsVisible(false);
|
||||
} else {
|
||||
// Scrolling up
|
||||
setIsVisible(true);
|
||||
}
|
||||
lastScrollY.current = currentScrollY;
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
*/
|
||||
}, []);
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
// Add a small delay to ensure button click is processed first
|
||||
setTimeout(() => {
|
||||
if (switcherRef.current && !switcherRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, 10);
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const switchLanguage = (newLocale: Locale) => {
|
||||
console.log('Switching language from', locale, 'to', newLocale);
|
||||
console.log('Current pathname:', pathname);
|
||||
|
||||
try {
|
||||
// Get the current path segments
|
||||
const pathSegments = pathname.split('/').filter(Boolean);
|
||||
console.log('Path segments:', pathSegments);
|
||||
|
||||
// Remove the current locale from the beginning if it exists
|
||||
if (pathSegments[0] && locales.includes(pathSegments[0] as Locale)) {
|
||||
pathSegments.shift();
|
||||
}
|
||||
|
||||
// Construct the new path with the new locale
|
||||
const newPath = `/${newLocale}${pathSegments.length > 0 ? '/' + pathSegments.join('/') : ''}`;
|
||||
console.log('New path:', newPath);
|
||||
|
||||
// Use replace instead of push to avoid history issues
|
||||
router.replace(newPath);
|
||||
setIsOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Error switching language:', error);
|
||||
// Fallback to simple navigation
|
||||
router.replace(`/${newLocale}`);
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render until mounted to avoid hydration issues
|
||||
if (!isMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={switcherRef}
|
||||
className={`fixed right-4 md:right-6 top-1/2 -translate-y-1/2 transition-all duration-300 ${
|
||||
isVisible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
|
||||
}`}
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
isolation: 'isolate',
|
||||
zIndex: 999999,
|
||||
position: 'fixed',
|
||||
}}
|
||||
data-oid="taerglg"
|
||||
>
|
||||
{/* Main Switcher Button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('Button clicked, current isOpen:', isOpen);
|
||||
const newState = !isOpen;
|
||||
setIsOpen(newState);
|
||||
console.log('Setting isOpen to:', newState);
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="group relative bg-slate-800/90 border-2 border-green-400/50 rounded-full p-3 shadow-2xl hover:bg-slate-700/90 hover:border-green-400 transition-all duration-200 cursor-pointer"
|
||||
title={`Current: ${localeNames[locale]}`}
|
||||
type="button"
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
touchAction: 'manipulation',
|
||||
zIndex: 1000000,
|
||||
position: 'relative',
|
||||
}}
|
||||
data-oid="_:fx8.e"
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8" data-oid="di.5x8v">
|
||||
<span className="text-2xl" data-oid="ll7ebjo">
|
||||
{localeFlags[locale]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tooltip */}
|
||||
<div
|
||||
className="absolute right-full mr-3 top-1/2 -translate-y-1/2 bg-slate-800/95 backdrop-blur-md text-white px-3 py-2 rounded-lg text-sm font-medium opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap"
|
||||
data-oid=":kya.x4"
|
||||
>
|
||||
{localeNames[locale]}
|
||||
<div
|
||||
className="absolute left-full top-1/2 -translate-y-1/2 border-4 border-transparent border-l-slate-800/95"
|
||||
data-oid="01-w6mc"
|
||||
></div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Language Options */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="absolute right-full mr-3 md:mr-4 top-1/2 -translate-y-1/2 bg-slate-800/98 rounded-2xl shadow-2xl border-2 border-green-400/30 overflow-hidden min-w-[180px] md:min-w-[200px]"
|
||||
style={{
|
||||
zIndex: 1000001,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
data-oid="e1v:81b"
|
||||
>
|
||||
<div className="p-2" data-oid="xcuqg9t">
|
||||
<div
|
||||
className="px-3 py-2 text-xs font-medium text-white/60 uppercase tracking-wider border-b border-white/10 mb-2"
|
||||
data-oid="_jrtjyl"
|
||||
>
|
||||
Select Language
|
||||
</div>
|
||||
|
||||
<div className="space-y-1" data-oid="tg0y3c4">
|
||||
{locales.map((loc) => (
|
||||
<button
|
||||
key={loc}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('Language option clicked:', loc);
|
||||
// Add immediate feedback
|
||||
e.currentTarget.style.backgroundColor =
|
||||
'rgba(34, 197, 94, 0.3)';
|
||||
setTimeout(() => {
|
||||
switchLanguage(loc);
|
||||
}, 100);
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={`w-full text-left px-3 py-3 rounded-xl flex items-center space-x-3 transition-all duration-200 cursor-pointer ${
|
||||
locale === loc
|
||||
? 'bg-green-500/30 text-green-400 border border-green-500/50'
|
||||
: 'text-white hover:bg-green-500/20 hover:text-green-400'
|
||||
}`}
|
||||
type="button"
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
touchAction: 'manipulation',
|
||||
zIndex: 1000002,
|
||||
}}
|
||||
data-oid="i3cq3gh"
|
||||
>
|
||||
<span className="text-lg" data-oid="kl5sqzg">
|
||||
{localeFlags[loc]}
|
||||
</span>
|
||||
<div className="flex-1" data-oid="ump7m:6">
|
||||
<span className="font-medium" data-oid="lsnf7ck">
|
||||
{localeNames[loc]}
|
||||
</span>
|
||||
{locale === loc && (
|
||||
<div
|
||||
className="text-xs text-green-400/70 mt-0.5"
|
||||
data-oid="6e9d:gj"
|
||||
>
|
||||
Current
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{locale === loc && (
|
||||
<svg
|
||||
className="w-4 h-4 text-green-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
data-oid="v2bjs6b"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
data-oid="mmqle:5"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow pointing to the button */}
|
||||
<div
|
||||
className="absolute left-full top-1/2 -translate-y-1/2 border-8 border-transparent border-l-slate-800/95"
|
||||
data-oid="0gukon1"
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtle glow effect */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-full bg-green-400/20 animate-pulse pointer-events-none"
|
||||
style={{ zIndex: -1 }}
|
||||
data-oid="g55eh56"
|
||||
></div>
|
||||
|
||||
{/* Floating indicator */}
|
||||
<div
|
||||
className="absolute -top-1 -right-1 w-3 h-3 bg-green-400 rounded-full animate-bounce pointer-events-none"
|
||||
style={{ zIndex: -1 }}
|
||||
data-oid="1t6b6v9"
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
components/Footer.tsx
Normal file
172
components/Footer.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { Locale, locales, localeNames, localeFlags } from '../lib/i18n';
|
||||
import { getTranslations } from '../lib/translations';
|
||||
|
||||
interface FooterProps {
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export default function Footer({ locale }: FooterProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const t = getTranslations(locale);
|
||||
|
||||
const switchLanguage = (newLocale: Locale) => {
|
||||
// Get the current path segments
|
||||
const pathSegments = pathname.split('/').filter(Boolean);
|
||||
|
||||
// Remove the current locale from the beginning if it exists
|
||||
if (pathSegments[0] && locales.includes(pathSegments[0] as Locale)) {
|
||||
pathSegments.shift();
|
||||
}
|
||||
|
||||
// Construct the new path with the new locale
|
||||
const newPath = `/${newLocale}${pathSegments.length > 0 ? '/' + pathSegments.join('/') : ''}`;
|
||||
|
||||
router.push(newPath);
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="relative z-10 bg-slate-900/95 backdrop-blur-md border-t border-white/10">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
{/* Brand Section */}
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<div className="text-2xl font-bold text-white mb-4">
|
||||
Nebula <span className="text-green-400">Cloud</span>
|
||||
</div>
|
||||
<p className="text-white/70 mb-6 max-w-md">{t.hero.subtitle}</p>
|
||||
|
||||
{/* Language Switcher in Footer */}
|
||||
<div className="mb-6">
|
||||
<h4 className="text-white font-medium mb-3">
|
||||
Language / 语言 / 언어 / 言語
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{locales.map((loc) => (
|
||||
<button
|
||||
key={loc}
|
||||
onClick={() => switchLanguage(loc)}
|
||||
className={`flex items-center space-x-2 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
locale === loc
|
||||
? 'bg-green-500/20 text-green-400 border border-green-500/30'
|
||||
: 'text-white/70 hover:text-white hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span>{localeFlags[loc]}</span>
|
||||
<span>{localeNames[loc]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div>
|
||||
<h4 className="text-white font-medium mb-4">Quick Links</h4>
|
||||
<div className="space-y-2">
|
||||
<a
|
||||
href={`/${locale}#hero`}
|
||||
className="block text-white/70 hover:text-green-400 transition-colors duration-200"
|
||||
>
|
||||
{t.nav.heroSection}
|
||||
</a>
|
||||
<a
|
||||
href={`/${locale}#about`}
|
||||
className="block text-white/70 hover:text-green-400 transition-colors duration-200"
|
||||
>
|
||||
{t.nav.aboutEcoLife}
|
||||
</a>
|
||||
<a
|
||||
href={`/${locale}/products`}
|
||||
className="block text-white/70 hover:text-green-400 transition-colors duration-200"
|
||||
>
|
||||
{t.nav.products}
|
||||
</a>
|
||||
<a
|
||||
href={`/${locale}#blog`}
|
||||
className="block text-white/70 hover:text-green-400 transition-colors duration-200"
|
||||
>
|
||||
{t.nav.blog}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Info */}
|
||||
<div>
|
||||
<h4 className="text-white font-medium mb-4">{t.nav.contact}</h4>
|
||||
<div className="space-y-2 text-white/70">
|
||||
<p>support@eco-life.com</p>
|
||||
<p>+1 (555) 123-4567</p>
|
||||
<div className="flex space-x-4 mt-4">
|
||||
<a
|
||||
href="#"
|
||||
className="text-white/70 hover:text-green-400 transition-colors duration-200"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="text-white/70 hover:text-green-400 transition-colors duration-200"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M22.46 6c-.77.35-1.6.58-2.46.69.88-.53 1.56-1.37 1.88-2.38-.83.5-1.75.85-2.72 1.05C18.37 4.5 17.26 4 16 4c-2.35 0-4.27 1.92-4.27 4.29 0 .34.04.67.11.98C8.28 9.09 5.11 7.38 3 4.79c-.37.63-.58 1.37-.58 2.15 0 1.49.75 2.81 1.91 3.56-.71 0-1.37-.2-1.95-.5v.03c0 2.08 1.48 3.82 3.44 4.21a4.22 4.22 0 0 1-1.93.07 4.28 4.28 0 0 0 4 2.98 8.521 8.521 0 0 1-5.33 1.84c-.34 0-.68-.02-1.02-.06C3.44 20.29 5.7 21 8.12 21 16 21 20.33 14.46 20.33 8.79c0-.19 0-.37-.01-.56.84-.6 1.56-1.36 2.14-2.23z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="text-white/70 hover:text-green-400 transition-colors duration-200"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="border-t border-white/10 mt-8 pt-8 flex flex-col md:flex-row justify-between items-center">
|
||||
<p className="text-white/60 text-sm">© 2024 Nebula Cloud. All rights reserved.</p>
|
||||
<div className="flex space-x-6 mt-4 md:mt-0">
|
||||
<a
|
||||
href="#"
|
||||
className="text-white/60 hover:text-white text-sm transition-colors duration-200"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="text-white/60 hover:text-white text-sm transition-colors duration-200"
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="text-white/60 hover:text-white text-sm transition-colors duration-200"
|
||||
>
|
||||
Cookie Policy
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
130
components/Navigation.tsx
Normal file
130
components/Navigation.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Locale } from '../lib/i18n';
|
||||
import { getTranslations } from '../lib/translations';
|
||||
|
||||
interface NavigationProps {
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
export default function Navigation({ locale = 'en' }: NavigationProps) {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const t = getTranslations(locale);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 50);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const navItems = [
|
||||
{ href: `/${locale}#hero`, label: t.nav.heroSection },
|
||||
{ href: `/${locale}#about`, label: t.nav.aboutEcoLife },
|
||||
{ href: `/${locale}/products`, label: t.nav.products },
|
||||
{ href: `/${locale}/blog`, label: t.nav.blog },
|
||||
{ href: `/${locale}/contact`, label: t.nav.contact },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
isScrolled
|
||||
? 'bg-slate-900/95 backdrop-blur-md border-b border-white/10'
|
||||
: 'bg-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0">
|
||||
<a
|
||||
href={`/${locale}`}
|
||||
className="text-2xl font-bold text-white hover:text-green-400 transition-colors duration-200"
|
||||
>
|
||||
Nebula <span className="text-green-400">Cloud</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:block">
|
||||
<div className="ml-10 flex items-baseline space-x-8">
|
||||
{navItems.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="text-white/90 hover:text-green-400 px-3 py-2 rounded-md text-sm font-medium transition-colors duration-200"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download Button */}
|
||||
<div className="hidden md:block">
|
||||
<button className="bg-green-500 hover:bg-green-600 text-white px-6 py-2 rounded-full font-medium transition-all duration-200 transform hover:scale-105">
|
||||
{t.nav.download}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<div className="md:hidden">
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className="text-white hover:text-green-400 p-2"
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden">
|
||||
<div className="px-2 pt-2 pb-3 space-y-1 bg-slate-900/95 backdrop-blur-md rounded-lg mt-2">
|
||||
{navItems.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="text-white/90 hover:text-green-400 block px-3 py-2 rounded-md text-base font-medium transition-colors duration-200"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
<button className="w-full bg-green-500 hover:bg-green-600 text-white px-6 py-2 rounded-full font-medium transition-all duration-200 mt-4">
|
||||
{t.nav.download}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
20
lib/i18n.ts
Normal file
20
lib/i18n.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export const locales = ['en', 'zh-CN', 'zh-TW', 'ko', 'ja'] as const;
|
||||
export type Locale = (typeof locales)[number];
|
||||
|
||||
export const defaultLocale: Locale = 'en';
|
||||
|
||||
export const localeNames: Record<Locale, string> = {
|
||||
en: 'English',
|
||||
'zh-CN': '简体中文',
|
||||
'zh-TW': '繁體中文',
|
||||
ko: '한국어',
|
||||
ja: '日本語',
|
||||
};
|
||||
|
||||
export const localeFlags: Record<Locale, string> = {
|
||||
en: '🇺🇸',
|
||||
'zh-CN': '🇨🇳',
|
||||
'zh-TW': '🇹🇼',
|
||||
ko: '🇰🇷',
|
||||
ja: '🇯🇵',
|
||||
};
|
||||
88
lib/seo-utils.ts
Normal file
88
lib/seo-utils.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { Locale, locales, defaultLocale } from './i18n';
|
||||
import { getTranslations } from './translations';
|
||||
|
||||
export function updateDocumentMeta(locale: Locale) {
|
||||
const translations = getTranslations(locale);
|
||||
|
||||
// Update document title
|
||||
document.title = translations.meta.title;
|
||||
|
||||
// Update meta description
|
||||
updateMetaTag('description', translations.meta.description);
|
||||
|
||||
// Update HTML lang attribute
|
||||
document.documentElement.lang = locale;
|
||||
}
|
||||
|
||||
function updateMetaTag(name: string, content: string) {
|
||||
let meta = document.querySelector(`meta[name="${name}"]`) as HTMLMetaElement;
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.name = name;
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.content = content;
|
||||
}
|
||||
|
||||
export function generateAlternateLinks(pathname: string, baseUrl: string = '') {
|
||||
return locales.map((locale) => ({
|
||||
hrefLang: locale,
|
||||
href:
|
||||
locale === defaultLocale ? `${baseUrl}${pathname}` : `${baseUrl}/${locale}${pathname}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export function generateCanonicalUrl(pathname: string, locale: Locale, baseUrl: string = '') {
|
||||
return locale === defaultLocale ? `${baseUrl}${pathname}` : `${baseUrl}/${locale}${pathname}`;
|
||||
}
|
||||
|
||||
export function generateStructuredData(
|
||||
type: 'WebSite' | 'BlogPosting' | 'Organization',
|
||||
data: any,
|
||||
locale: Locale,
|
||||
) {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://your-domain.com';
|
||||
const translations = getTranslations(locale);
|
||||
|
||||
const baseStructuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': type,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case 'WebSite':
|
||||
return {
|
||||
...baseStructuredData,
|
||||
name: translations.meta.title,
|
||||
description: translations.meta.description,
|
||||
url: baseUrl,
|
||||
inLanguage: locale,
|
||||
availableLanguage: locales,
|
||||
};
|
||||
|
||||
case 'BlogPosting':
|
||||
return {
|
||||
...baseStructuredData,
|
||||
headline: data.title,
|
||||
description: data.excerpt,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: data.author,
|
||||
},
|
||||
datePublished: data.date,
|
||||
dateModified: data.date,
|
||||
inLanguage: locale,
|
||||
};
|
||||
|
||||
case 'Organization':
|
||||
return {
|
||||
...baseStructuredData,
|
||||
name: translations.meta.title,
|
||||
description: translations.meta.description,
|
||||
url: baseUrl,
|
||||
};
|
||||
|
||||
default:
|
||||
return baseStructuredData;
|
||||
}
|
||||
}
|
||||
125
lib/sitemap-generator.ts
Normal file
125
lib/sitemap-generator.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { locales, defaultLocale, Locale } from './i18n';
|
||||
import { getTranslations } from './translations';
|
||||
|
||||
export interface SitemapEntry {
|
||||
url: string;
|
||||
lastModified: Date;
|
||||
changeFrequency: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
||||
priority: number;
|
||||
alternates?: {
|
||||
languages: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
export function generateStaticPages(baseUrl: string): SitemapEntry[] {
|
||||
const staticPages = [
|
||||
{ path: '', priority: 1.0, changeFrequency: 'daily' as const },
|
||||
{ path: '/blog', priority: 0.8, changeFrequency: 'weekly' as const },
|
||||
{ path: '/contact', priority: 0.6, changeFrequency: 'monthly' as const },
|
||||
{ path: '/products', priority: 0.7, changeFrequency: 'weekly' as const },
|
||||
];
|
||||
|
||||
const entries: SitemapEntry[] = [];
|
||||
|
||||
locales.forEach((locale) => {
|
||||
staticPages.forEach((page) => {
|
||||
const url =
|
||||
locale === defaultLocale
|
||||
? `${baseUrl}${page.path}`
|
||||
: `${baseUrl}/${locale}${page.path}`;
|
||||
|
||||
entries.push({
|
||||
url,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: page.changeFrequency,
|
||||
priority: page.priority,
|
||||
alternates: {
|
||||
languages: Object.fromEntries(
|
||||
locales.map((loc) => [
|
||||
loc,
|
||||
loc === defaultLocale
|
||||
? `${baseUrl}${page.path}`
|
||||
: `${baseUrl}/${loc}${page.path}`,
|
||||
]),
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function generateBlogPages(baseUrl: string): SitemapEntry[] {
|
||||
// Get blog post slugs from your translations or database
|
||||
const blogSlugs = ['featured', 'post1', 'post2', 'post3', 'post4', 'post5'];
|
||||
|
||||
const entries: SitemapEntry[] = [];
|
||||
|
||||
locales.forEach((locale) => {
|
||||
blogSlugs.forEach((slug) => {
|
||||
const url =
|
||||
locale === defaultLocale
|
||||
? `${baseUrl}/blog/${slug}`
|
||||
: `${baseUrl}/${locale}/blog/${slug}`;
|
||||
|
||||
entries.push({
|
||||
url,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
alternates: {
|
||||
languages: Object.fromEntries(
|
||||
locales.map((loc) => [
|
||||
loc,
|
||||
loc === defaultLocale
|
||||
? `${baseUrl}/blog/${slug}`
|
||||
: `${baseUrl}/${loc}/blog/${slug}`,
|
||||
]),
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function generateSitemap(baseUrl: string): SitemapEntry[] {
|
||||
const staticPages = generateStaticPages(baseUrl);
|
||||
const blogPages = generateBlogPages(baseUrl);
|
||||
|
||||
return [...staticPages, ...blogPages];
|
||||
}
|
||||
|
||||
// Helper function to get all available blog post slugs
|
||||
export function getBlogPostSlugs(): string[] {
|
||||
// In a real application, you might fetch this from a CMS or database
|
||||
// For now, we'll use the static list from your existing blog structure
|
||||
return ['featured', 'post1', 'post2', 'post3', 'post4', 'post5'];
|
||||
}
|
||||
|
||||
// Helper function to validate if a blog post exists
|
||||
export function validateBlogPost(slug: string, locale: Locale): boolean {
|
||||
const translations = getTranslations(locale);
|
||||
const posts = translations.blog.posts;
|
||||
return slug in posts;
|
||||
}
|
||||
|
||||
export function getSitemapStats(baseUrl: string): {
|
||||
urlCount: number;
|
||||
staticPages: number;
|
||||
blogPages: number;
|
||||
languages: number;
|
||||
} {
|
||||
const staticPages = generateStaticPages(baseUrl);
|
||||
const blogPages = generateBlogPages(baseUrl);
|
||||
const sitemap = [...staticPages, ...blogPages];
|
||||
|
||||
return {
|
||||
urlCount: sitemap.length,
|
||||
staticPages: staticPages.length,
|
||||
blogPages: blogPages.length,
|
||||
languages: locales.length,
|
||||
};
|
||||
}
|
||||
289
lib/sitemap-utils.ts
Normal file
289
lib/sitemap-utils.ts
Normal file
@ -0,0 +1,289 @@
|
||||
import { locales, defaultLocale } from './i18n';
|
||||
import { getSitemapStats, generateSitemap } from './sitemap-generator';
|
||||
|
||||
/**
|
||||
* 站点地图验证工具
|
||||
*/
|
||||
export class SitemapValidator {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证所有 URL 是否可访问
|
||||
*/
|
||||
async validateUrls(): Promise<{
|
||||
valid: string[];
|
||||
invalid: string[];
|
||||
errors: Array<{ url: string; error: string }>;
|
||||
}> {
|
||||
const sitemap = generateSitemap(this.baseUrl);
|
||||
const valid: string[] = [];
|
||||
const invalid: string[] = [];
|
||||
const errors: Array<{ url: string; error: string }> = [];
|
||||
|
||||
for (const entry of sitemap) {
|
||||
try {
|
||||
const response = await fetch(entry.url, { method: 'HEAD' });
|
||||
if (response.ok) {
|
||||
valid.push(entry.url);
|
||||
} else {
|
||||
invalid.push(entry.url);
|
||||
errors.push({
|
||||
url: entry.url,
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
invalid.push(entry.url);
|
||||
errors.push({
|
||||
url: entry.url,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { valid, invalid, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查重复的 URL
|
||||
*/
|
||||
checkDuplicates(): string[] {
|
||||
const sitemap = generateSitemap(this.baseUrl);
|
||||
const urls = sitemap.map((entry) => entry.url);
|
||||
const duplicates: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const url of urls) {
|
||||
if (seen.has(url)) {
|
||||
duplicates.push(url);
|
||||
} else {
|
||||
seen.add(url);
|
||||
}
|
||||
}
|
||||
|
||||
return duplicates;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成站点地图报告
|
||||
*/
|
||||
generateReport(): {
|
||||
stats: ReturnType<typeof getSitemapStats>;
|
||||
duplicates: string[];
|
||||
recommendations: string[];
|
||||
} {
|
||||
const stats = getSitemapStats(this.baseUrl);
|
||||
const duplicates = this.checkDuplicates();
|
||||
const recommendations: string[] = [];
|
||||
|
||||
// 生成建议
|
||||
if (duplicates.length > 0) {
|
||||
recommendations.push(`发现 ${duplicates.length} 个重复的 URL,请检查站点地图生成逻辑`);
|
||||
}
|
||||
|
||||
if (stats.urlCount > 50000) {
|
||||
recommendations.push('站点地图包含超过 50,000 个 URL,建议分割为多个站点地图文件');
|
||||
}
|
||||
|
||||
if (stats.blogPages === 0) {
|
||||
recommendations.push('未发现博客页面,请确认博客内容是否正确配置');
|
||||
}
|
||||
|
||||
return {
|
||||
stats,
|
||||
duplicates,
|
||||
recommendations,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点地图提交工具
|
||||
*/
|
||||
export class SitemapSubmitter {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成搜索引擎提交 URL
|
||||
*/
|
||||
getSubmissionUrls(): Record<string, string> {
|
||||
const sitemapUrl = `${this.baseUrl}/sitemap.xml`;
|
||||
|
||||
return {
|
||||
google: `https://www.google.com/ping?sitemap=${encodeURIComponent(sitemapUrl)}`,
|
||||
bing: `https://www.bing.com/ping?sitemap=${encodeURIComponent(sitemapUrl)}`,
|
||||
yandex: `https://webmaster.yandex.com/ping?sitemap=${encodeURIComponent(sitemapUrl)}`,
|
||||
baidu: `https://ping.baidu.com/ping/RPC2?sitemap=${encodeURIComponent(sitemapUrl)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动提交站点地图到搜索引擎
|
||||
*/
|
||||
async submitToSearchEngines(): Promise<{
|
||||
success: string[];
|
||||
failed: Array<{ engine: string; error: string }>;
|
||||
}> {
|
||||
const urls = this.getSubmissionUrls();
|
||||
const success: string[] = [];
|
||||
const failed: Array<{ engine: string; error: string }> = [];
|
||||
|
||||
for (const [engine, url] of Object.entries(urls)) {
|
||||
try {
|
||||
const response = await fetch(url, { method: 'GET' });
|
||||
if (response.ok) {
|
||||
success.push(engine);
|
||||
} else {
|
||||
failed.push({
|
||||
engine,
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
engine,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { success, failed };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成站点地图索引文件
|
||||
*/
|
||||
export function generateSitemapIndex(baseUrl: string): string {
|
||||
const lastmod = new Date().toISOString();
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<sitemap>
|
||||
<loc>${baseUrl}/sitemap.xml</loc>
|
||||
<lastmod>${lastmod}</lastmod>
|
||||
</sitemap>
|
||||
</sitemapindex>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成人类可读的站点地图 HTML
|
||||
*/
|
||||
export function generateHtmlSitemap(baseUrl: string): string {
|
||||
const sitemap = generateSitemap(baseUrl);
|
||||
const stats = getSitemapStats(baseUrl);
|
||||
|
||||
// 按页面类型分组
|
||||
const groupedPages = sitemap.reduce(
|
||||
(acc, entry) => {
|
||||
let category = 'other';
|
||||
|
||||
if (entry.url.includes('/blog/') && !entry.url.endsWith('/blog')) {
|
||||
category = 'blog-posts';
|
||||
} else if (entry.url.endsWith('/blog')) {
|
||||
category = 'blog';
|
||||
} else if (entry.url.endsWith('/contact')) {
|
||||
category = 'contact';
|
||||
} else if (entry.url.endsWith('/products')) {
|
||||
category = 'products';
|
||||
} else if (entry.url === baseUrl || entry.url.match(/\/[a-z-]+$/)) {
|
||||
category = 'main';
|
||||
}
|
||||
|
||||
if (!acc[category]) acc[category] = [];
|
||||
acc[category].push(entry);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof sitemap>,
|
||||
);
|
||||
|
||||
const categoryNames = {
|
||||
main: '主要页面',
|
||||
blog: '博客',
|
||||
'blog-posts': '博客文章',
|
||||
contact: '联系我们',
|
||||
products: '产品',
|
||||
other: '其他页面',
|
||||
};
|
||||
|
||||
let html = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>站点地图 - Eco Life</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 40px; line-height: 1.6; }
|
||||
.header { border-bottom: 2px solid #10b981; padding-bottom: 20px; margin-bottom: 30px; }
|
||||
.stats { background: #f0fdf4; padding: 20px; border-radius: 8px; margin-bottom: 30px; }
|
||||
.category { margin-bottom: 30px; }
|
||||
.category h2 { color: #10b981; border-bottom: 1px solid #d1d5db; padding-bottom: 10px; }
|
||||
.url-list { list-style: none; padding: 0; }
|
||||
.url-item { margin: 8px 0; padding: 12px; background: #f9fafb; border-radius: 6px; }
|
||||
.url-item a { text-decoration: none; color: #1f2937; font-weight: 500; }
|
||||
.url-item a:hover { color: #10b981; }
|
||||
.url-meta { font-size: 0.875rem; color: #6b7280; margin-top: 4px; }
|
||||
.priority { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; }
|
||||
.priority-high { background: #fef3c7; color: #92400e; }
|
||||
.priority-medium { background: #dbeafe; color: #1e40af; }
|
||||
.priority-low { background: #f3f4f6; color: #374151; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>站点地图</h1>
|
||||
<p>生成时间: ${new Date().toLocaleString('zh-CN')}</p>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<h3>统计信息</h3>
|
||||
<p><strong>总页面数:</strong> ${stats.urlCount}</p>
|
||||
<p><strong>静态页面:</strong> ${stats.staticPages}</p>
|
||||
<p><strong>博客文章:</strong> ${stats.blogPages}</p>
|
||||
<p><strong>支持语言:</strong> ${stats.languages} (${locales.join(', ')})</p>
|
||||
</div>`;
|
||||
|
||||
Object.entries(groupedPages).forEach(([category, pages]) => {
|
||||
html += `
|
||||
<div class="category">
|
||||
<h2>${categoryNames[category as keyof typeof categoryNames] || category}</h2>
|
||||
<ul class="url-list">`;
|
||||
|
||||
pages.forEach((page) => {
|
||||
const priorityClass =
|
||||
page.priority >= 0.8
|
||||
? 'priority-high'
|
||||
: page.priority >= 0.6
|
||||
? 'priority-medium'
|
||||
: 'priority-low';
|
||||
|
||||
html += `
|
||||
<li class="url-item">
|
||||
<a href="${page.url}" target="_blank">${page.url}</a>
|
||||
<div class="url-meta">
|
||||
<span class="priority ${priorityClass}">优先级: ${page.priority}</span>
|
||||
<span>更新频率: ${page.changeFrequency}</span>
|
||||
<span>最后修改: ${page.lastModified.toLocaleDateString('zh-CN')}</span>
|
||||
</div>
|
||||
</li>`;
|
||||
});
|
||||
|
||||
html += `
|
||||
</ul>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
html += `
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return html;
|
||||
}
|
||||
1091
lib/translations.ts
Normal file
1091
lib/translations.ts
Normal file
File diff suppressed because it is too large
Load Diff
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
54
middleware.ts
Normal file
54
middleware.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { locales, defaultLocale } from './lib/i18n';
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
// Check if there is any supported locale in the pathname
|
||||
const pathname = request.nextUrl.pathname;
|
||||
|
||||
// Check if the pathname already includes a locale
|
||||
const pathnameIsMissingLocale = locales.every(
|
||||
(locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`,
|
||||
);
|
||||
|
||||
// Redirect if there is no locale
|
||||
if (pathnameIsMissingLocale) {
|
||||
// Try to get locale from Accept-Language header
|
||||
const locale = getLocale(request) || defaultLocale;
|
||||
|
||||
return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url));
|
||||
}
|
||||
}
|
||||
|
||||
function getLocale(request: NextRequest): string | undefined {
|
||||
// Get locale from Accept-Language header
|
||||
const acceptLanguage = request.headers.get('accept-language');
|
||||
|
||||
if (!acceptLanguage) return undefined;
|
||||
|
||||
// Parse Accept-Language header
|
||||
const languages = acceptLanguage.split(',').map((lang) => lang.split(';')[0].trim());
|
||||
|
||||
// Find matching locale
|
||||
for (const lang of languages) {
|
||||
if (locales.includes(lang as any)) {
|
||||
return lang;
|
||||
}
|
||||
|
||||
// Check for language without region (e.g., 'zh' matches 'zh-CN')
|
||||
const langWithoutRegion = lang.split('-')[0];
|
||||
const matchingLocale = locales.find((locale) => locale.startsWith(langWithoutRegion));
|
||||
|
||||
if (matchingLocale) {
|
||||
return matchingLocale;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// Skip all internal paths (_next)
|
||||
'/((?!_next|api|favicon.ico|.*\\..*).*)',
|
||||
],
|
||||
};
|
||||
6
next.config.mjs
Normal file
6
next.config.mjs
Normal file
@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
distDir: 'build',
|
||||
};
|
||||
export default nextConfig;
|
||||
5582
package-lock.json
generated
Normal file
5582
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@onlook/next-template",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.438.0",
|
||||
"next": "14.2.23",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "^15.1.6",
|
||||
"postcss": "^8",
|
||||
"prettier": "^3.3.3",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
8
postcss.config.mjs
Normal file
8
postcss.config.mjs
Normal file
@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
public/images/logo-text.png
Normal file
BIN
public/images/logo-text.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
27
public/manifest.json
Normal file
27
public/manifest.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "Eco-Life - Rebuild Earth",
|
||||
"short_name": "Eco-Life",
|
||||
"description": "Join the movement to create a sustainable future through interactive environmental gaming",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#22c55e",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
}
|
||||
],
|
||||
"categories": ["games", "education", "environment"],
|
||||
"lang": "en",
|
||||
"dir": "ltr"
|
||||
}
|
||||
19
public/robots.txt
Normal file
19
public/robots.txt
Normal file
@ -0,0 +1,19 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# Sitemaps
|
||||
Sitemap: https://eco-life.com/sitemap.xml
|
||||
|
||||
# Specific rules for different locales
|
||||
Allow: /en/
|
||||
Allow: /zh-CN/
|
||||
Allow: /zh-TW/
|
||||
Allow: /ko/
|
||||
Allow: /ja/
|
||||
|
||||
# Block admin areas (if any)
|
||||
Disallow: /admin/
|
||||
Disallow: /api/
|
||||
|
||||
# Crawl delay
|
||||
Crawl-delay: 1
|
||||
67
tailwind.config.ts
Normal file
67
tailwind.config.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
backgroundImage: {
|
||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||
'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))'
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
},
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))'
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))'
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))'
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))'
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))'
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))'
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))'
|
||||
},
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
chart: {
|
||||
'1': 'hsl(var(--chart-1))',
|
||||
'2': 'hsl(var(--chart-2))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
};
|
||||
export default config;
|
||||
40
tsconfig.json
Normal file
40
tsconfig.json
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"build/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user