init
@@ -0,0 +1,50 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
#vs code
|
||||
/.vscode
|
||||
package-lock.json
|
||||
standalone-deploy.tar.gz
|
||||
|
||||
#ai-agent
|
||||
/.kiro/
|
||||
AGENTS.md
|
||||
@@ -0,0 +1,218 @@
|
||||
# VisaService - Website Dịch Vụ Visa
|
||||
|
||||
Website dịch vụ visa chuyên nghiệp được xây dựng với Next.js 15, TypeScript và Tailwind CSS. Hỗ trợ tư vấn visa cho hơn 50 quốc gia với tỷ lệ thành công 99%.
|
||||
|
||||
## 🚀 Tính Năng
|
||||
|
||||
- **8 Trang Chính**: Trang chủ, Giới thiệu, Dịch vụ, Visa, Quốc gia, Bảng giá, Blog, Liên hệ
|
||||
- **Hệ Thống Đặt Lịch**: Đặt lịch tư vấn với chuyên gia
|
||||
- **Responsive Design**: Tối ưu cho mọi thiết bị
|
||||
- **JSON-Based Content**: Dễ dàng quản lý nội dung
|
||||
- **Interactive Components**: Form, accordion, filter, modal
|
||||
- **SEO Optimized**: Metadata và structure tối ưu
|
||||
|
||||
## 🛠️ Công Nghệ Sử Dụng
|
||||
|
||||
- **Framework**: Next.js 15 (App Router)
|
||||
- **Language**: TypeScript
|
||||
- **Styling**: Tailwind CSS
|
||||
- **Icons**: Emoji icons
|
||||
- **Deployment**: Docker support
|
||||
|
||||
## 📁 Cấu Trúc Thư Mục
|
||||
|
||||
```
|
||||
app/
|
||||
├── components/ # Shared components
|
||||
│ ├── Header.tsx # Navigation header
|
||||
│ └── Footer.tsx # Site footer
|
||||
├── about/ # Trang giới thiệu
|
||||
│ ├── about.json # Dữ liệu về công ty
|
||||
│ └── page.tsx # Component trang
|
||||
├── services/ # Trang dịch vụ
|
||||
│ ├── services.json # Dữ liệu dịch vụ
|
||||
│ └── page.tsx # Component trang
|
||||
├── visa/ # Trang visa
|
||||
│ ├── visa.json # Dữ liệu các loại visa
|
||||
│ └── page.tsx # Component trang
|
||||
├── countries/ # Trang quốc gia
|
||||
│ ├── countries.json # Dữ liệu quốc gia
|
||||
│ └── page.tsx # Component trang
|
||||
├── pricing/ # Trang bảng giá
|
||||
│ ├── pricing.json # Dữ liệu giá cả
|
||||
│ └── page.tsx # Component trang
|
||||
├── blog/ # Trang blog
|
||||
│ ├── blog.json # Dữ liệu bài viết
|
||||
│ └── page.tsx # Component trang
|
||||
├── contact/ # Trang liên hệ
|
||||
│ ├── contact.json # Thông tin liên hệ
|
||||
│ └── page.tsx # Component trang
|
||||
├── appointment/ # Trang đặt lịch
|
||||
│ ├── appointment.json # Dữ liệu booking
|
||||
│ └── page.tsx # Component trang
|
||||
├── layout.tsx # Root layout
|
||||
├── page.tsx # Trang chủ
|
||||
└── globals.css # Global styles
|
||||
```
|
||||
|
||||
## 🚀 Hướng Dẫn Chạy Dự Án
|
||||
|
||||
### Yêu Cầu Hệ Thống
|
||||
|
||||
- Node.js 18+
|
||||
- npm, yarn, hoặc pnpm
|
||||
|
||||
### Cài Đặt và Chạy
|
||||
|
||||
1. **Clone repository**
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd visa-service-website
|
||||
```
|
||||
|
||||
2. **Cài đặt dependencies**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# hoặc
|
||||
yarn install
|
||||
# hoặc
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. **Chạy development server**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# hoặc
|
||||
yarn dev
|
||||
# hoặc
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
4. **Mở trình duyệt**
|
||||
Truy cập [http://localhost:3000](http://localhost:3000) để xem website
|
||||
|
||||
### Scripts Có Sẵn
|
||||
|
||||
```bash
|
||||
npm run dev # Chạy development server
|
||||
npm run build # Build production
|
||||
npm run start # Chạy production server
|
||||
npm run lint # Kiểm tra code style
|
||||
```
|
||||
|
||||
## 🐳 Chạy Với Docker
|
||||
|
||||
### Development với Docker
|
||||
|
||||
```bash
|
||||
# Build và chạy container
|
||||
docker-compose up --build
|
||||
|
||||
# Chạy ở background
|
||||
docker-compose up -d
|
||||
|
||||
# Dừng container
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### Production với Docker
|
||||
|
||||
```bash
|
||||
# Build production image
|
||||
docker build -t visa-service .
|
||||
|
||||
# Chạy container
|
||||
docker run -p 3000:3000 visa-service
|
||||
```
|
||||
|
||||
## 📝 Tùy Chỉnh Nội Dung
|
||||
|
||||
### Cập Nhật Dữ Liệu
|
||||
|
||||
Mỗi trang có file JSON riêng chứa dữ liệu:
|
||||
|
||||
- `about/about.json` - Thông tin công ty
|
||||
- `services/services.json` - Danh sách dịch vụ
|
||||
- `visa/visa.json` - Các loại visa
|
||||
- `countries/countries.json` - Quốc gia hỗ trợ
|
||||
- `pricing/pricing.json` - Bảng giá dịch vụ
|
||||
- `blog/blog.json` - Bài viết blog
|
||||
- `contact/contact.json` - Thông tin liên hệ
|
||||
- `appointment/appointment.json` - Cấu hình đặt lịch
|
||||
|
||||
### Thêm Trang Mới
|
||||
|
||||
1. Tạo thư mục mới trong `app/`
|
||||
2. Tạo file `data.json` chứa dữ liệu
|
||||
3. Tạo file `page.tsx` với React component
|
||||
4. Cập nhật navigation trong `Header.tsx`
|
||||
|
||||
### Tùy Chỉnh Styling
|
||||
|
||||
- Sử dụng Tailwind CSS classes
|
||||
- Tùy chỉnh theme trong `tailwind.config.js`
|
||||
- Global styles trong `app/globals.css`
|
||||
|
||||
## 🌐 Deployment
|
||||
|
||||
### Vercel (Khuyến nghị)
|
||||
|
||||
1. Push code lên GitHub
|
||||
2. Kết nối repository với Vercel
|
||||
3. Deploy tự động
|
||||
|
||||
### Netlify
|
||||
|
||||
1. Build project: `npm run build`
|
||||
2. Upload thư mục `out/` lên Netlify
|
||||
|
||||
### VPS/Server
|
||||
|
||||
1. Build project: `npm run build`
|
||||
2. Chạy: `npm run start`
|
||||
3. Sử dụng PM2 hoặc Docker cho production
|
||||
|
||||
## 📱 Responsive Design
|
||||
|
||||
Website được tối ưu cho:
|
||||
|
||||
- **Mobile**: < 768px
|
||||
- **Tablet**: 768px - 1024px
|
||||
- **Desktop**: > 1024px
|
||||
|
||||
## 🔧 Cấu Hình Môi Trường
|
||||
|
||||
Tạo file `.env.local` (tùy chọn):
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_CONTACT_EMAIL=info@visaservice.com
|
||||
NEXT_PUBLIC_PHONE=1900-1234
|
||||
```
|
||||
|
||||
## 🤝 Đóng Góp
|
||||
|
||||
1. Fork repository
|
||||
2. Tạo feature branch: `git checkout -b feature/new-feature`
|
||||
3. Commit changes: `git commit -m 'Add new feature'`
|
||||
4. Push branch: `git push origin feature/new-feature`
|
||||
5. Tạo Pull Request
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - xem file [LICENSE](LICENSE) để biết thêm chi tiết.
|
||||
|
||||
## 📞 Hỗ Trợ
|
||||
|
||||
- **Email**: info@visaservice.com
|
||||
- **Phone**: 1900 1234
|
||||
- **Website**: [visaservice.com](https://visaservice.com)
|
||||
|
||||
---
|
||||
|
||||
**Phát triển bởi**: VisaService Team
|
||||
**Phiên bản**: 1.0.0
|
||||
**Cập nhật**: January 2025
|
||||
@@ -0,0 +1,400 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>IPv6 SUMMIT 2026 | Admin Console</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@600;700&family=Geist:wght@600&family=JetBrains+Mono:wght@500&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"secondary-fixed": "#ffddb2",
|
||||
"outline-variant": "#4e4639",
|
||||
"surface-container-highest": "#353534",
|
||||
"on-tertiary-fixed": "#1c1b20",
|
||||
"inverse-surface": "#e5e2e1",
|
||||
"tertiary-fixed": "#e5e1e8",
|
||||
"background": "#0F0F0F",
|
||||
"primary": "#e9c176",
|
||||
"surface-bright": "#3a3939",
|
||||
"inverse-on-surface": "#313030",
|
||||
"on-background": "#e5e2e1",
|
||||
"on-secondary-fixed": "#291800",
|
||||
"inverse-primary": "#775a19",
|
||||
"on-tertiary-fixed-variant": "#48464c",
|
||||
"on-primary": "#412d00",
|
||||
"surface-dim": "#131313",
|
||||
"on-surface": "#e5e2e1",
|
||||
"on-tertiary-container": "#3c3a40",
|
||||
"primary-fixed-dim": "#e9c176",
|
||||
"primary-fixed": "#ffdea5",
|
||||
"on-surface-variant": "#d1c5b4",
|
||||
"surface-container-lowest": "#0e0e0e",
|
||||
"on-primary-container": "#4e3700",
|
||||
"on-secondary-fixed-variant": "#5d4216",
|
||||
"secondary-container": "#604418",
|
||||
"tertiary-fixed-dim": "#c9c5cc",
|
||||
"surface-variant": "#353534",
|
||||
"primary-container": "#c5a059",
|
||||
"surface-container": "#201f1f",
|
||||
"surface-container-low": "#1c1b1b",
|
||||
"secondary": "#e9c088",
|
||||
"error": "#ffb4ab",
|
||||
"surface": "#131313",
|
||||
"on-primary-fixed-variant": "#5d4201",
|
||||
"tertiary-container": "#a7a4ab",
|
||||
"on-secondary-container": "#d9b27c",
|
||||
"error-container": "#93000a",
|
||||
"surface-tint": "#e9c176",
|
||||
"on-tertiary": "#313035",
|
||||
"on-error": "#690005",
|
||||
"surface-container-high": "#2a2a2a",
|
||||
"outline": "#9a8f80",
|
||||
"secondary-fixed-dim": "#e9c088",
|
||||
"on-primary-fixed": "#261900",
|
||||
"on-error-container": "#ffdad6",
|
||||
"tertiary": "#c9c5cc",
|
||||
"on-secondary": "#442b02"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"stack-sm": "8px",
|
||||
"stack-lg": "32px",
|
||||
"stack-md": "16px",
|
||||
"unit": "4px",
|
||||
"section-gap": "80px",
|
||||
"margin-safe": "40px",
|
||||
"gutter": "24px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"body-base": ["Inter"],
|
||||
"title-sm": ["Inter"],
|
||||
"display-lg": ["Space Grotesk"],
|
||||
"label-caps": ["Geist"],
|
||||
"headline-md": ["Space Grotesk"],
|
||||
"mono": ["JetBrains Mono"]
|
||||
},
|
||||
"fontSize": {
|
||||
"body-base": ["16px", {"lineHeight": "1.6", "letterSpacing": "0", "fontWeight": "400"}],
|
||||
"title-sm": ["20px", {"lineHeight": "1.4", "letterSpacing": "0.02em", "fontWeight": "600"}],
|
||||
"display-lg": ["48px", {"lineHeight": "1.1", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
||||
"label-caps": ["12px", {"lineHeight": "1", "letterSpacing": "0.1em", "fontWeight": "600"}],
|
||||
"headline-md": ["32px", {"lineHeight": "1.2", "letterSpacing": "-0.01em", "fontWeight": "600"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.glass-panel {
|
||||
background: rgba(26, 25, 30, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.gold-border-focus:focus {
|
||||
border-bottom: 1px solid #e9c176;
|
||||
outline: none;
|
||||
}
|
||||
.metallic-gold-gradient {
|
||||
background: linear-gradient(135deg, #e9c176 0%, #c5a059 100%);
|
||||
}
|
||||
.status-led {
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-on-surface font-body-base min-h-screen flex">
|
||||
<!-- Sidebar: Fixed, slim -->
|
||||
<aside class="fixed left-0 top-0 h-full w-20 md:w-24 bg-surface-container-lowest border-r border-outline-variant/20 flex flex-col items-center py-stack-lg z-50">
|
||||
<div class="mb-12">
|
||||
<div class="w-12 h-12 metallic-gold-gradient flex items-center justify-center rounded-lg shadow-lg">
|
||||
<span class="material-symbols-outlined text-on-primary text-3xl" style="font-variation-settings: 'FILL' 1;">cloud_done</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="flex flex-col gap-8 flex-1">
|
||||
<button class="group flex flex-col items-center gap-1 text-primary">
|
||||
<span class="material-symbols-outlined text-2xl transition-all duration-300 group-hover:scale-110" style="font-variation-settings: 'FILL' 1;">dashboard</span>
|
||||
<span class="font-label-caps text-[8px] uppercase tracking-widest opacity-0 group-hover:opacity-100 transition-opacity">Panel</span>
|
||||
</button>
|
||||
<button class="group flex flex-col items-center gap-1 text-on-surface-variant hover:text-primary">
|
||||
<span class="material-symbols-outlined text-2xl transition-all duration-300 group-hover:scale-110">group</span>
|
||||
<span class="font-label-caps text-[8px] uppercase tracking-widest opacity-0 group-hover:opacity-100 transition-opacity">Users</span>
|
||||
</button>
|
||||
<button class="group flex flex-col items-center gap-1 text-on-surface-variant hover:text-primary">
|
||||
<span class="material-symbols-outlined text-2xl transition-all duration-300 group-hover:scale-110">hourglass_empty</span>
|
||||
<span class="font-label-caps text-[8px] uppercase tracking-widest opacity-0 group-hover:opacity-100 transition-opacity">Pending</span>
|
||||
</button>
|
||||
<button class="group flex flex-col items-center gap-1 text-on-surface-variant hover:text-primary">
|
||||
<span class="material-symbols-outlined text-2xl transition-all duration-300 group-hover:scale-110">check_circle</span>
|
||||
<span class="font-label-caps text-[8px] uppercase tracking-widest opacity-0 group-hover:opacity-100 transition-opacity">Approved</span>
|
||||
</button>
|
||||
<button class="group flex flex-col items-center gap-1 text-on-surface-variant hover:text-primary">
|
||||
<span class="material-symbols-outlined text-2xl transition-all duration-300 group-hover:scale-110">cancel</span>
|
||||
<span class="font-label-caps text-[8px] uppercase tracking-widest opacity-0 group-hover:opacity-100 transition-opacity">Rejected</span>
|
||||
</button>
|
||||
</nav>
|
||||
<button class="group flex flex-col items-center gap-1 text-on-surface-variant hover:text-primary">
|
||||
<span class="material-symbols-outlined text-2xl transition-all duration-300 group-hover:rotate-45">settings</span>
|
||||
<span class="font-label-caps text-[8px] uppercase tracking-widest">Settings</span>
|
||||
</button>
|
||||
</aside>
|
||||
<!-- Main Content Wrapper -->
|
||||
<div class="flex-1 ml-20 md:ml-24 flex flex-col">
|
||||
<!-- Header: Sticky Glass Panel -->
|
||||
<header class="sticky top-0 w-full z-40 glass-panel py-4 px-gutter flex items-center justify-between border-b border-outline-variant/10">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="hidden md:block">
|
||||
<h1 class="font-display-lg text-title-sm text-primary uppercase tracking-tighter">Admin Console</h1>
|
||||
<p class="font-label-caps text-on-surface-variant text-[10px]">IPv6 SUMMIT 2026 / Infrastructure Core</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 max-w-xl mx-8">
|
||||
<div class="relative group">
|
||||
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-outline">search</span>
|
||||
<input class="w-full bg-surface-container-low border-none rounded-full py-2.5 pl-12 pr-4 text-body-base text-on-surface focus:ring-1 focus:ring-primary/30 gold-border-focus transition-all" placeholder="Command Search (e.g. attendee name or company)" type="text"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex flex-col items-end hidden lg:flex">
|
||||
<span class="font-title-sm text-sm text-on-surface">Director Chen</span>
|
||||
<span class="font-label-caps text-[10px] text-primary">Systems Admin</span>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full border border-primary/40 p-0.5 overflow-hidden">
|
||||
<img alt="Admin Profile" class="w-full h-full object-cover rounded-full bg-surface-container-high" data-alt="A professional, high-resolution portrait illustration of a tech executive. The person has a modern, minimalist appearance with subtle glowing cybernetic highlights. The background is a soft, dark gray charcoal with elegant gold lighting accents to match a premium high-tech UI theme." src="https://lh3.googleusercontent.com/aida-public/AB6AXuBL5I0HI-Q2dFLcJYnL30VBYPbnA_kiLY4AfC84YxaO3pOwYn3wVHKoCU78x037a47B6SVaLtkXaIWkgeNJl1Y3ymDBWGzVq_jey8olRQvKfUfRl4coRUe5NennfYDf2SnyPHxmWjXzEeE1yE_sbWQNv0HBLWeKXMBzx7NEzdjO9ZgCKQe7LHIV7HP3kfyw_V7rKNwPwCRUi0Za5P6-cOn6yvMOb-rbOu_dNJymmqL_lGFYtJG9rfaracohIUqfdQRfWiD9ZQZhaDKO"/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Main Dashboard Canvas -->
|
||||
<main class="p-gutter max-w-7xl mx-auto w-full flex flex-col gap-stack-lg">
|
||||
<!-- Top Stat Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-gutter">
|
||||
<!-- Total -->
|
||||
<div class="glass-panel p-6 rounded-xl relative overflow-hidden group">
|
||||
<div class="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<span class="material-symbols-outlined text-6xl">database</span>
|
||||
</div>
|
||||
<p class="font-label-caps text-on-surface-variant mb-2">Total Requests</p>
|
||||
<h3 class="font-display-lg text-headline-md text-on-surface">1,284</h3>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<span class="text-xs text-primary">+12% vs last week</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Pending -->
|
||||
<div class="glass-panel p-6 rounded-xl relative overflow-hidden border-l-4 border-secondary group">
|
||||
<p class="font-label-caps text-secondary mb-2 flex items-center gap-2">
|
||||
<span class="status-led w-2 h-2 rounded-full bg-secondary"></span>
|
||||
Pending Approval
|
||||
</p>
|
||||
<h3 class="font-display-lg text-headline-md text-on-surface">42</h3>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<span class="text-xs text-secondary-fixed">Critical threshold: 50</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Confirmed -->
|
||||
<div class="glass-panel p-6 rounded-xl relative overflow-hidden border-l-4 border-primary group">
|
||||
<p class="font-label-caps text-primary mb-2 flex items-center gap-2">
|
||||
<span class="status-led w-2 h-2 rounded-full bg-primary"></span>
|
||||
Confirmed
|
||||
</p>
|
||||
<h3 class="font-display-lg text-headline-md text-on-surface">856</h3>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<span class="text-xs text-primary-fixed-dim">Capacity at 74%</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Conversion -->
|
||||
<div class="glass-panel p-6 rounded-xl relative overflow-hidden group">
|
||||
<p class="font-label-caps text-on-surface-variant mb-2">Conversion Rate</p>
|
||||
<h3 class="font-display-lg text-headline-md text-on-surface">67%</h3>
|
||||
<div class="mt-4 w-full bg-surface-container-high h-1.5 rounded-full overflow-hidden">
|
||||
<div class="h-full metallic-gold-gradient w-2/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Filter Bar -->
|
||||
<section class="glass-panel px-6 py-4 rounded-xl flex flex-col lg:flex-row items-center justify-between gap-stack-md">
|
||||
<div class="flex flex-wrap items-center gap-4 w-full lg:w-auto">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="font-label-caps text-[10px] text-outline">Status</label>
|
||||
<select class="bg-surface-container border border-outline-variant/30 text-on-surface text-sm rounded-lg px-3 py-1.5 focus:ring-primary/50 focus:border-primary">
|
||||
<option>All Operations</option>
|
||||
<option>Pending</option>
|
||||
<option>Approved</option>
|
||||
<option>Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="font-label-caps text-[10px] text-outline">Company</label>
|
||||
<select class="bg-surface-container border border-outline-variant/30 text-on-surface text-sm rounded-lg px-3 py-1.5 focus:ring-primary/50 focus:border-primary">
|
||||
<option>Global Tier 1</option>
|
||||
<option>Cloud Providers</option>
|
||||
<option>Enterprise</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="font-label-caps text-[10px] text-outline">Timeframe</label>
|
||||
<input class="bg-surface-container border border-outline-variant/30 text-on-surface text-sm rounded-lg px-3 py-1.5" type="date"/>
|
||||
</div>
|
||||
</div>
|
||||
<button class="w-full lg:w-auto border border-primary text-primary font-label-caps px-6 py-2 rounded transition-all hover:bg-primary hover:text-on-primary flex items-center justify-center gap-2">
|
||||
<span class="material-symbols-outlined text-sm">download</span>
|
||||
Export to Excel
|
||||
</button>
|
||||
</section>
|
||||
<!-- Data Table -->
|
||||
<div class="glass-panel rounded-xl overflow-hidden shadow-2xl">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-surface-container border-b border-outline-variant/20">
|
||||
<th class="px-6 py-4 font-label-caps text-outline text-[11px] uppercase tracking-widest">ID</th>
|
||||
<th class="px-6 py-4 font-label-caps text-outline text-[11px] uppercase tracking-widest">Submit Date</th>
|
||||
<th class="px-6 py-4 font-label-caps text-outline text-[11px] uppercase tracking-widest">Name</th>
|
||||
<th class="px-6 py-4 font-label-caps text-outline text-[11px] uppercase tracking-widest">Company</th>
|
||||
<th class="px-6 py-4 font-label-caps text-outline text-[11px] uppercase tracking-widest">Email / Phone</th>
|
||||
<th class="px-6 py-4 font-label-caps text-outline text-[11px] uppercase tracking-widest">Status</th>
|
||||
<th class="px-6 py-4 font-label-caps text-outline text-[11px] uppercase tracking-widest text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-outline-variant/10">
|
||||
<!-- Row 1 -->
|
||||
<tr class="hover:bg-surface-container-high/40 transition-colors">
|
||||
<td class="px-6 py-4 font-mono text-sm text-primary">#V6-4821</td>
|
||||
<td class="px-6 py-4 text-sm text-on-surface-variant">24 OCT 2025</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="font-title-sm text-sm">Elena Rodriguez</div>
|
||||
<div class="text-xs text-outline">Network Architect</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm">Cloudflare, Inc.</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm">e.rodriguez@cloudflare.com</div>
|
||||
<div class="text-xs text-outline">+1 (555) 012-9988</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="px-3 py-1 rounded-full text-[10px] font-label-caps border border-primary text-primary bg-primary/10 flex items-center gap-1.5 w-fit">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-primary status-led"></span>
|
||||
Approved
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button class="p-2 rounded-lg hover:bg-surface-container-highest text-on-surface-variant hover:text-on-surface transition-all">
|
||||
<span class="material-symbols-outlined text-lg">visibility</span>
|
||||
</button>
|
||||
<button class="p-2 rounded-lg hover:bg-primary/20 text-primary transition-all">
|
||||
<span class="material-symbols-outlined text-lg">edit</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Row 2 -->
|
||||
<tr class="hover:bg-surface-container-high/40 transition-colors">
|
||||
<td class="px-6 py-4 font-mono text-sm text-primary">#V6-4819</td>
|
||||
<td class="px-6 py-4 text-sm text-on-surface-variant">23 OCT 2025</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="font-title-sm text-sm">Marcus Thorne</div>
|
||||
<div class="text-xs text-outline">Senior Devops</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm">Akamai Technologies</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm">m.thorne@akamai.io</div>
|
||||
<div class="text-xs text-outline">+44 20 7946 0123</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="px-3 py-1 rounded-full text-[10px] font-label-caps border border-secondary text-secondary bg-secondary/10 flex items-center gap-1.5 w-fit">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-secondary status-led"></span>
|
||||
Pending
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button class="p-2 rounded-lg hover:bg-surface-container-highest text-on-surface-variant hover:text-on-surface">
|
||||
<span class="material-symbols-outlined text-lg">visibility</span>
|
||||
</button>
|
||||
<button class="p-2 rounded-lg hover:bg-primary text-on-primary" title="Approve">
|
||||
<span class="material-symbols-outlined text-lg" style="font-variation-settings: 'FILL' 1;">check</span>
|
||||
</button>
|
||||
<button class="p-2 rounded-lg hover:bg-error/20 text-error" title="Reject">
|
||||
<span class="material-symbols-outlined text-lg">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Row 3 -->
|
||||
<tr class="hover:bg-surface-container-high/40 transition-colors">
|
||||
<td class="px-6 py-4 font-mono text-sm text-primary">#V6-4790</td>
|
||||
<td class="px-6 py-4 text-sm text-on-surface-variant">22 OCT 2025</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="font-title-sm text-sm">Kenji Yamamoto</div>
|
||||
<div class="text-xs text-outline">CTO</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm">NTT Data Japan</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm">yamamoto@ntt.jp</div>
|
||||
<div class="text-xs text-outline">+81 3 1234 5678</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="px-3 py-1 rounded-full text-[10px] font-label-caps border border-error text-error bg-error/10 flex items-center gap-1.5 w-fit">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-error status-led"></span>
|
||||
Rejected
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button class="p-2 rounded-lg hover:bg-surface-container-highest text-on-surface-variant hover:text-on-surface">
|
||||
<span class="material-symbols-outlined text-lg">visibility</span>
|
||||
</button>
|
||||
<button class="p-2 rounded-lg hover:bg-surface-container-highest text-on-surface-variant">
|
||||
<span class="material-symbols-outlined text-lg">history</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Pagination -->
|
||||
<div class="px-6 py-4 border-t border-outline-variant/10 flex items-center justify-between">
|
||||
<p class="text-xs text-outline">Showing 1 to 10 of 1,284 entries</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="w-8 h-8 flex items-center justify-center rounded border border-outline-variant/30 text-outline hover:border-primary hover:text-primary transition-all">
|
||||
<span class="material-symbols-outlined text-sm">chevron_left</span>
|
||||
</button>
|
||||
<button class="w-8 h-8 flex items-center justify-center rounded bg-primary text-on-primary text-xs font-bold">1</button>
|
||||
<button class="w-8 h-8 flex items-center justify-center rounded border border-outline-variant/30 text-on-surface-variant hover:border-primary hover:text-primary transition-all text-xs">2</button>
|
||||
<button class="w-8 h-8 flex items-center justify-center rounded border border-outline-variant/30 text-on-surface-variant hover:border-primary hover:text-primary transition-all text-xs">3</button>
|
||||
<button class="w-8 h-8 flex items-center justify-center rounded border border-outline-variant/30 text-outline hover:border-primary hover:text-primary transition-all">
|
||||
<span class="material-symbols-outlined text-sm">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- Footer -->
|
||||
<footer class="mt-auto border-t border-outline-variant/20 py-8 px-gutter flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="font-headline-md text-sm text-primary uppercase">IPv6 SUMMIT 2026</span>
|
||||
<span class="text-xs text-outline">| Systems Infrastructure Core v2.4.0</span>
|
||||
</div>
|
||||
<p class="text-[11px] font-label-caps text-outline tracking-widest">© 2026 IPv6 for AI & Data Centre Summit. Infrastructure for the Future.</p>
|
||||
<div class="flex items-center gap-6">
|
||||
<a class="text-[10px] font-label-caps text-on-surface-variant hover:text-primary transition-colors" href="#">Privacy</a>
|
||||
<a class="text-[10px] font-label-caps text-on-surface-variant hover:text-primary transition-colors" href="#">Terms</a>
|
||||
<a class="text-[10px] font-label-caps text-on-surface-variant hover:text-primary transition-colors" href="#">Support</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<!-- Background Decorative Glows -->
|
||||
<div class="fixed top-[-10%] left-[-10%] w-[40%] h-[40%] bg-primary/5 blur-[120px] rounded-full -z-10"></div>
|
||||
<div class="fixed bottom-[-10%] right-[-10%] w-[30%] h-[30%] bg-secondary/5 blur-[100px] rounded-full -z-10"></div>
|
||||
</body></html>
|
||||
@@ -0,0 +1,323 @@
|
||||
import {
|
||||
BlogListResponse,
|
||||
BlogDetailResponse,
|
||||
BlogFeaturedResponse,
|
||||
BlogRecentResponse,
|
||||
CategoryListResponse,
|
||||
CategoryDetailResponse,
|
||||
TagListResponse,
|
||||
TagDetailResponse,
|
||||
BlogQueryParams,
|
||||
} from '../types/blog';
|
||||
|
||||
/**
|
||||
* Lấy API URL từ environment variable
|
||||
*/
|
||||
const getApiUrl = (): string | null => {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
||||
// Nếu chưa cấu hình API URL, trả về null để skip việc gọi API (tránh lỗi fetch failed)
|
||||
if (!apiUrl) {
|
||||
return null;
|
||||
}
|
||||
return apiUrl;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch blog list từ API, trả về null nếu thất bại (để caller fallback sang local JSON)
|
||||
*/
|
||||
export const fetchBlogList = async (
|
||||
params?: BlogQueryParams
|
||||
): Promise<BlogListResponse | null> => {
|
||||
const apiUrl = getApiUrl();
|
||||
if (!apiUrl) return null;
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params?.page) queryParams.append('page', params.page.toString());
|
||||
if (params?.limit) queryParams.append('limit', params.limit.toString());
|
||||
if (params?.category) queryParams.append('category', params.category);
|
||||
if (params?.tag) queryParams.append('tag', params.tag);
|
||||
if (params?.search) queryParams.append('search', params.search);
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
const url = `${apiUrl}/api/blog${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: BlogListResponse = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch blog detail by slug từ API, trả về null nếu thất bại
|
||||
*/
|
||||
export const fetchBlogDetail = async (
|
||||
slug: string
|
||||
): Promise<BlogDetailResponse | null> => {
|
||||
const apiUrl = getApiUrl();
|
||||
if (!apiUrl) return null;
|
||||
|
||||
const url = `${apiUrl}/api/blog/${slug}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: BlogDetailResponse = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch featured blogs từ API, trả về null nếu thất bại
|
||||
*/
|
||||
export const fetchFeaturedBlogs = async (
|
||||
limit: number = 5
|
||||
): Promise<BlogFeaturedResponse | null> => {
|
||||
const apiUrl = getApiUrl();
|
||||
if (!apiUrl) return null;
|
||||
|
||||
const url = `${apiUrl}/api/blog/featured?limit=${limit}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: BlogFeaturedResponse = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch recent blogs từ API, trả về null nếu thất bại
|
||||
*/
|
||||
export const fetchRecentBlogs = async (
|
||||
limit: number = 5
|
||||
): Promise<BlogRecentResponse | null> => {
|
||||
const apiUrl = getApiUrl();
|
||||
if (!apiUrl) return null;
|
||||
|
||||
const url = `${apiUrl}/api/blog/recent?limit=${limit}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: BlogRecentResponse = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch categories list từ API, trả về null nếu thất bại
|
||||
*/
|
||||
export const fetchCategories = async (): Promise<CategoryListResponse | null> => {
|
||||
const apiUrl = getApiUrl();
|
||||
if (!apiUrl) return null;
|
||||
|
||||
const url = `${apiUrl}/api/blog/categories`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: CategoryListResponse = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch category detail by slug từ API, trả về null nếu thất bại
|
||||
*/
|
||||
export const fetchCategoryDetail = async (
|
||||
slug: string
|
||||
): Promise<CategoryDetailResponse | null> => {
|
||||
const apiUrl = getApiUrl();
|
||||
if (!apiUrl) return null;
|
||||
|
||||
const url = `${apiUrl}/api/blog/categories/${slug}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: CategoryDetailResponse = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch tags list từ API, trả về null nếu thất bại
|
||||
*/
|
||||
export const fetchTags = async (): Promise<TagListResponse | null> => {
|
||||
const apiUrl = getApiUrl();
|
||||
if (!apiUrl) return null;
|
||||
|
||||
const url = `${apiUrl}/api/blog/tags`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: TagListResponse = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch popular tags từ API, trả về null nếu thất bại
|
||||
*/
|
||||
export const fetchPopularTags = async (
|
||||
limit: number = 10
|
||||
): Promise<TagListResponse | null> => {
|
||||
const apiUrl = getApiUrl();
|
||||
if (!apiUrl) return null;
|
||||
|
||||
const url = `${apiUrl}/api/blog/tags/popular?limit=${limit}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: TagListResponse = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch tag detail by slug từ API, trả về null nếu thất bại
|
||||
*/
|
||||
export const fetchTagDetail = async (
|
||||
slug: string
|
||||
): Promise<TagDetailResponse | null> => {
|
||||
const apiUrl = getApiUrl();
|
||||
if (!apiUrl) return null;
|
||||
|
||||
const url = `${apiUrl}/api/blog/tags/${slug}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: TagDetailResponse = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch blogs by category từ API, trả về null nếu thất bại
|
||||
*/
|
||||
export const fetchBlogsByCategory = async (
|
||||
categorySlug: string,
|
||||
params?: Omit<BlogQueryParams, 'category'>
|
||||
): Promise<BlogListResponse | null> => {
|
||||
const apiUrl = getApiUrl();
|
||||
if (!apiUrl) return null;
|
||||
|
||||
const categoryResponse = await fetchCategoryDetail(categorySlug);
|
||||
if (!categoryResponse) return null;
|
||||
|
||||
const categoryName = categoryResponse.data.name;
|
||||
return fetchBlogList({ ...params, category: categoryName });
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch blogs by tag từ API, trả về null nếu thất bại
|
||||
*/
|
||||
export const fetchBlogsByTag = async (
|
||||
tagSlug: string,
|
||||
params?: Omit<BlogQueryParams, 'tag'>
|
||||
): Promise<BlogListResponse | null> => {
|
||||
const apiUrl = getApiUrl();
|
||||
if (!apiUrl) return null;
|
||||
|
||||
const tagResponse = await fetchTagDetail(tagSlug);
|
||||
if (!tagResponse) return null;
|
||||
|
||||
const tagName = tagResponse.data.name;
|
||||
return fetchBlogList({ ...params, tag: tagName });
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import axiosInstance from "../lib/axios";
|
||||
|
||||
export const fetchContentPageData = async <T>(
|
||||
endpoint: string,
|
||||
): Promise<T | null> => {
|
||||
try {
|
||||
const response = await axiosInstance.get<T>(endpoint);
|
||||
return response.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { HeaderData, HeaderMenuItem } from '@/types/header';
|
||||
|
||||
const CMS_API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
export async function fetchHeaderData(): Promise<HeaderData> {
|
||||
const res = await fetch(`${CMS_API_URL}/api/header`, {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch header: ${res.status}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (!json.success) {
|
||||
throw new Error(json.message || 'Header API error');
|
||||
}
|
||||
|
||||
return json.data as HeaderData;
|
||||
}
|
||||
|
||||
export async function fetchHeaderMenu(): Promise<HeaderMenuItem[]> {
|
||||
const res = await fetch(`${CMS_API_URL}/api/header-menu`, {
|
||||
next: { revalidate: 60 },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch menu: ${res.status}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (!json.success) {
|
||||
throw new Error(json.message || 'Menu API error');
|
||||
}
|
||||
|
||||
return json.data as HeaderMenuItem[];
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Lấy API URL từ environment variable
|
||||
*/
|
||||
const getApiUrl = (): string | null => {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
if (!apiUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return apiUrl;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch home page data từ API
|
||||
* @returns Promise<any>
|
||||
* @throws Error nếu fetch thất bại
|
||||
*/
|
||||
export const fetchHomeData = async (): Promise<any> => {
|
||||
const apiUrl = getApiUrl();
|
||||
if (!apiUrl) return null;
|
||||
|
||||
const url = `${apiUrl}/api/home`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
return null; // Trả về null để fallback sang dữ liệu local
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import axiosInstance from "@/lib/axios";
|
||||
|
||||
export interface NewsletterSubscribePayload {
|
||||
email: string;
|
||||
pageUrl?: string;
|
||||
}
|
||||
|
||||
export async function subscribeNewsletter(
|
||||
payload: NewsletterSubscribePayload,
|
||||
): Promise<void> {
|
||||
await axiosInstance.post("/api/newsletter/subscribe", payload);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { FiltersData, Programme } from '../types/programme';
|
||||
|
||||
/**
|
||||
* Lấy API URL từ environment variable
|
||||
*/
|
||||
const getApiUrl = (): string | null => {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
||||
if (!apiUrl) return null;
|
||||
return apiUrl;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch tất cả programmes từ API, trả về null nếu thất bại
|
||||
*/
|
||||
export const fetchProgrammes = async (): Promise<Programme[] | null> => {
|
||||
const apiUrl = getApiUrl();
|
||||
if (!apiUrl) return null;
|
||||
|
||||
const url = `${apiUrl}/api/programmes`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: Programme[] = await response.json();
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch một programme theo id/slug, trả về null nếu thất bại
|
||||
*/
|
||||
export const fetchProgrammeById = async (id: string): Promise<Programme | null> => {
|
||||
const apiUrl = getApiUrl();
|
||||
if (!apiUrl) return null;
|
||||
|
||||
const url = `${apiUrl}/api/programmes/${id}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: Programme = await response.json();
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch những programmes được đánh dấu featured (selected: true)
|
||||
* trả về null nếu thất bại
|
||||
*/
|
||||
export const fetchFeaturedProgrammes = async (): Promise<Programme[] | null> => {
|
||||
const data = await fetchProgrammes();
|
||||
if (!data) return null;
|
||||
return data.filter((p) => p.selected === true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch programme filter options from CMS.
|
||||
*/
|
||||
export const fetchProgrammeFilters = async (): Promise<FiltersData | null> => {
|
||||
const apiUrl = getApiUrl();
|
||||
if (!apiUrl) return null;
|
||||
|
||||
const url = `${apiUrl}/api/programmes/filters`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: FiltersData = await response.json();
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import axiosInstance from "@/lib/axios";
|
||||
|
||||
export type SubmissionSource = "home" | "request" | "contact" | "partnership";
|
||||
|
||||
export interface SubmitFormPayload {
|
||||
source: SubmissionSource;
|
||||
pageUrl: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function submitForm(payload: SubmitFormPayload): Promise<void> {
|
||||
await axiosInstance.post("/api/submissions", payload);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Header from "@/app/components/layout/Header/Header";
|
||||
import Footer from "@/app/components/layout/Footer/Footer";
|
||||
|
||||
export default function SiteLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
{children}
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import home from "@/app/data/home.json";
|
||||
import HeroSection from "@/app/components/home/HeroSection";
|
||||
import VisionSection from "@/app/components/home/VisionSection";
|
||||
import StatsSection from "@/app/components/home/StatsSection";
|
||||
import AgendaSection from "@/app/components/home/AgendaSection";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="pt-24 lg:pt-32">
|
||||
<HeroSection data={home.hero} />
|
||||
<VisionSection data={home.vision} />
|
||||
<StatsSection data={home.stats} />
|
||||
<AgendaSection data={home.agenda} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import Link from "next/link";
|
||||
import RegistrationForm from "@/app/components/forms/RegistrationForm";
|
||||
import data from "@/app/data/registration.json";
|
||||
|
||||
export default function RegistrationPage() {
|
||||
return (
|
||||
<main className="pt-[160px] pb-[80px] px-[var(--spacing-gutter)] flex justify-center">
|
||||
<div className="w-full max-w-[1280px]">
|
||||
<div className="max-w-[800px] mx-auto">
|
||||
<div className="mb-[32px]">
|
||||
<Link
|
||||
className="group flex items-center gap-2 text-outline hover:text-primary transition-colors"
|
||||
href="/"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px] transition-transform group-hover:-translate-x-1">
|
||||
arrow_back
|
||||
</span>
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mb-[32px]">
|
||||
<h1 className="font-[var(--font-display-lg)] text-[clamp(2.25rem,3vw+1rem,4.5rem)] text-on-surface mb-2 leading-[1.1]">
|
||||
{data.title}
|
||||
</h1>
|
||||
<p className="text-on-surface-variant text-lg">{data.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel p-[32px] md:p-12 rounded-xl shadow-2xl relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-primary/20 to-transparent" />
|
||||
<RegistrationForm data={data} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type IndustryOption = { value: string; label: string };
|
||||
|
||||
export default function RegistrationForm({
|
||||
data,
|
||||
}: {
|
||||
data: {
|
||||
sections: {
|
||||
personal: { title: string; fullName: string; phone: string; email: string };
|
||||
professional: { title: string; company: string; jobTitle: string };
|
||||
industry: { title: string; label: string; placeholder: string; options: IndustryOption[] };
|
||||
additional: { title: string; label: string; placeholder: string };
|
||||
};
|
||||
submit: { label: string; disclaimer: string };
|
||||
};
|
||||
}) {
|
||||
const industries = useMemo(() => data.sections.industry.options, [data.sections.industry.options]);
|
||||
const [form, setForm] = useState({
|
||||
fullName: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
company: "",
|
||||
jobTitle: "",
|
||||
industry: "",
|
||||
notes: "",
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-[32px]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
// frontend-only scope
|
||||
console.log("[registration]", form);
|
||||
alert("Saved locally (frontend-only).");
|
||||
}}
|
||||
>
|
||||
<div className="space-y-[16px]">
|
||||
<h2 className="font-[var(--font-display-lg)] text-[20px] text-primary border-l-2 border-primary pl-3">
|
||||
{data.sections.personal.title}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-[16px]">
|
||||
<div className="space-y-1">
|
||||
<label className="text-outline uppercase tracking-wider">{data.sections.personal.fullName}</label>
|
||||
<input
|
||||
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface placeholder:text-outline/40 transition-all"
|
||||
placeholder="John Doe"
|
||||
value={form.fullName}
|
||||
onChange={(e) => setForm((s) => ({ ...s, fullName: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-outline uppercase tracking-wider">{data.sections.personal.phone}</label>
|
||||
<input
|
||||
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface placeholder:text-outline/40 transition-all"
|
||||
placeholder="+84 ..."
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm((s) => ({ ...s, phone: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-1">
|
||||
<label className="text-outline uppercase tracking-wider">{data.sections.personal.email}</label>
|
||||
<input
|
||||
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface placeholder:text-outline/40 transition-all"
|
||||
placeholder="john.doe@company.com"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((s) => ({ ...s, email: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-white/5" />
|
||||
|
||||
<div className="space-y-[16px]">
|
||||
<h2 className="font-[var(--font-display-lg)] text-[20px] text-primary border-l-2 border-primary pl-3">
|
||||
{data.sections.professional.title}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-[16px]">
|
||||
<div className="space-y-1">
|
||||
<label className="text-outline uppercase tracking-wider">{data.sections.professional.company}</label>
|
||||
<input
|
||||
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface placeholder:text-outline/40 transition-all"
|
||||
placeholder="Company"
|
||||
value={form.company}
|
||||
onChange={(e) => setForm((s) => ({ ...s, company: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-outline uppercase tracking-wider">{data.sections.professional.jobTitle}</label>
|
||||
<input
|
||||
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface placeholder:text-outline/40 transition-all"
|
||||
placeholder="Network Architect"
|
||||
value={form.jobTitle}
|
||||
onChange={(e) => setForm((s) => ({ ...s, jobTitle: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-white/5" />
|
||||
|
||||
<div className="space-y-[16px]">
|
||||
<h2 className="font-[var(--font-display-lg)] text-[20px] text-primary border-l-2 border-primary pl-3">
|
||||
{data.sections.industry.title}
|
||||
</h2>
|
||||
<div className="space-y-1">
|
||||
<label className="text-outline uppercase tracking-wider">{data.sections.industry.label}</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface appearance-none transition-all"
|
||||
value={form.industry}
|
||||
onChange={(e) => setForm((s) => ({ ...s, industry: e.target.value }))}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{data.sections.industry.placeholder}
|
||||
</option>
|
||||
{industries.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="material-symbols-outlined absolute right-4 top-1/2 -translate-y-1/2 text-outline pointer-events-none">
|
||||
expand_more
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-white/5" />
|
||||
|
||||
<div className="space-y-[16px]">
|
||||
<h2 className="font-[var(--font-display-lg)] text-[20px] text-primary border-l-2 border-primary pl-3">
|
||||
{data.sections.additional.title}
|
||||
</h2>
|
||||
<div className="space-y-1">
|
||||
<label className="text-outline uppercase tracking-wider">{data.sections.additional.label}</label>
|
||||
<textarea
|
||||
className="w-full bg-surface-container-low border border-white/10 rounded-lg px-4 py-3 text-on-surface placeholder:text-outline/40 transition-all resize-none"
|
||||
placeholder={data.sections.additional.placeholder}
|
||||
rows={4}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm((s) => ({ ...s, notes: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-[16px]">
|
||||
<button
|
||||
className="w-full gold-gradient-bg py-5 rounded-lg text-on-primary font-[var(--font-display-lg)] text-[18px] font-bold shadow-[0_10px_30px_rgba(197,160,89,0.15)] hover:shadow-[0_15px_40px_rgba(197,160,89,0.25)] hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98] transition-all"
|
||||
type="submit"
|
||||
>
|
||||
{data.submit.label}
|
||||
</button>
|
||||
<p className="mt-[16px] text-center text-outline text-[11px] tracking-widest opacity-60 uppercase">
|
||||
{data.submit.disclaimer}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
export default function AgendaSection({
|
||||
data,
|
||||
}: {
|
||||
data: {
|
||||
id: string;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
items: { order: string; time: string; title: string; description: string }[];
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<section className="py-16 lg:py-32 max-w-hd mx-auto px-[var(--spacing-gutter)]" id={data.id}>
|
||||
<div className="flex flex-col xl:flex-row justify-between xl:items-end mb-12 lg:mb-24 gap-8">
|
||||
<div className="max-w-3xl">
|
||||
<span className="font-[var(--font-label-caps)] text-xs lg:text-sm text-primary block mb-4 lg:mb-8 tracking-[0.25em]">
|
||||
{data.eyebrow}
|
||||
</span>
|
||||
<h2 className="font-[var(--font-display-lg)] text-on-surface uppercase tracking-tight text-[clamp(2rem,4vw+1rem,4.5rem)]">
|
||||
{data.title}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-on-surface-variant/60 text-base lg:text-[20px] max-w-md xl:text-right">
|
||||
{data.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 lg:gap-10">
|
||||
{data.items.map((item) => (
|
||||
<div
|
||||
key={item.order}
|
||||
className="glass-panel p-8 lg:p-12 rounded-3xl relative overflow-hidden group hover:border-primary/60 transition-colors"
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-6 lg:p-8">
|
||||
<span className="font-[var(--font-display-lg)] text-2xl lg:text-[40px] text-primary/20 group-hover:text-primary transition-colors">
|
||||
{item.order}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-6 lg:mb-10">
|
||||
<span className="font-[var(--font-display-lg)] text-2xl lg:text-[36px] text-primary">
|
||||
{item.time}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-[var(--font-display-lg)] text-2xl lg:text-[32px] text-on-surface mb-4 lg:mb-6">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-sm lg:text-[18px] text-on-surface-variant/80 leading-relaxed mb-6 lg:mb-8">
|
||||
{item.description}
|
||||
</p>
|
||||
<div className="w-full h-1 bg-primary/10 group-hover:bg-primary transition-colors duration-500" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function HeroSection({
|
||||
data,
|
||||
}: {
|
||||
data: {
|
||||
badgeIcon: string;
|
||||
badgeText: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
primaryCta: { label: string; href: string };
|
||||
secondaryCta: { label: string; href: string };
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<section className="relative min-h-[90vh] lg:min-h-screen flex items-center overflow-hidden bg-background hero-glow px-[var(--spacing-gutter)]">
|
||||
<div className="relative z-10 max-w-hd mx-auto w-full">
|
||||
<div className="max-w-7xl">
|
||||
<div className="inline-flex items-center gap-3 lg:gap-4 px-4 lg:px-8 py-2 lg:py-3 rounded-full bg-surface-container-high border border-primary/30 mb-8 lg:mb-16">
|
||||
<span className="material-symbols-outlined text-primary text-[20px] lg:text-[26px]">
|
||||
{data.badgeIcon}
|
||||
</span>
|
||||
<span className="font-[var(--font-label-caps)] text-xs lg:text-[16px] text-on-surface">
|
||||
{data.badgeText}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="font-[var(--font-display-lg)] text-on-surface mb-6 lg:mb-10 leading-[1.1] text-[clamp(2.25rem,5vw+1rem,6.875rem)]">
|
||||
{data.title}
|
||||
</h1>
|
||||
<p className="text-on-surface-variant text-lg lg:text-[28px] mb-10 lg:mb-20 max-w-4xl leading-relaxed opacity-90">
|
||||
{data.subtitle}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 lg:gap-10">
|
||||
<Link
|
||||
href={data.primaryCta.href}
|
||||
className="gold-gradient-bg text-on-primary px-8 lg:px-16 py-5 lg:py-7 font-[var(--font-display-lg)] text-sm lg:text-[20px] font-bold uppercase tracking-widest rounded-xl hover:shadow-[0_0_50px_rgba(197,160,89,0.4)] transition-all text-center"
|
||||
>
|
||||
{data.primaryCta.label}
|
||||
</Link>
|
||||
<Link
|
||||
href={data.secondaryCta.href}
|
||||
className="bg-surface-container-high border border-primary/40 text-on-surface px-8 lg:px-16 py-5 lg:py-7 font-[var(--font-display-lg)] text-sm lg:text-[20px] font-bold uppercase tracking-widest rounded-xl hover:bg-surface-container-highest transition-all text-center"
|
||||
>
|
||||
{data.secondaryCta.label}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export default function StatsSection({
|
||||
data,
|
||||
}: {
|
||||
data: { id: string; items: { value: string; label: string }[] };
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="py-16 lg:py-32 bg-surface-container-low border-y border-primary/10"
|
||||
id={data.id}
|
||||
>
|
||||
<div className="max-w-hd mx-auto px-[var(--spacing-gutter)]">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-12 lg:gap-32 text-center">
|
||||
{data.items.map((x) => (
|
||||
<div key={x.label} className="space-y-3 lg:space-y-6">
|
||||
<div className="font-[var(--font-display-lg)] text-5xl lg:text-[96px] text-primary">
|
||||
{x.value}
|
||||
</div>
|
||||
<div className="font-[var(--font-label-caps)] text-sm lg:text-[18px] text-on-surface/80 tracking-widest uppercase">
|
||||
{x.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
export default function VisionSection({
|
||||
data,
|
||||
}: {
|
||||
data: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
paragraphs: string[];
|
||||
cards: { icon: string; title: string; description: string }[];
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<section className="py-16 lg:py-32 max-w-hd mx-auto px-[var(--spacing-gutter)]">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 lg:gap-32 items-start">
|
||||
<div className="lg:col-span-5">
|
||||
<span className="font-[var(--font-label-caps)] text-xs lg:text-sm text-primary mb-6 lg:mb-10 block tracking-[0.25em]">
|
||||
{data.eyebrow}
|
||||
</span>
|
||||
<h2 className="font-[var(--font-display-lg)] text-on-surface mb-8 lg:mb-12 leading-tight text-[clamp(2rem,4vw+1rem,4.5rem)]">
|
||||
{data.title}
|
||||
</h2>
|
||||
<div className="space-y-6 lg:space-y-10 text-on-surface-variant text-lg lg:text-[22px] leading-relaxed">
|
||||
{data.paragraphs.map((p) => (
|
||||
<p key={p}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:col-span-7 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 lg:gap-10">
|
||||
{data.cards.map((c) => (
|
||||
<div
|
||||
key={c.title}
|
||||
className="glass-panel p-8 lg:p-12 rounded-2xl group transition-all duration-500 hover:lg:-translate-y-4 hover:shadow-2xl hover:shadow-primary/5"
|
||||
>
|
||||
<span className="material-symbols-outlined text-primary text-[40px] lg:text-[56px] mb-8 lg:mb-12 block">
|
||||
{c.icon}
|
||||
</span>
|
||||
<h3 className="font-[var(--font-display-lg)] text-xl lg:text-[28px] text-on-surface mb-4 lg:mb-6">
|
||||
{c.title}
|
||||
</h3>
|
||||
<p className="text-sm lg:text-[18px] text-on-surface-variant/80 leading-relaxed">
|
||||
{c.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import Link from "next/link";
|
||||
import siteData from "@/app/data/site.json";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="w-full bg-surface-container-low border-t border-white/5 py-12 lg:py-20">
|
||||
<div className="flex flex-col xl:flex-row justify-between items-center px-[var(--spacing-gutter)] gap-12 w-full max-w-hd mx-auto">
|
||||
<div className="font-[var(--font-label-caps)] text-xl lg:text-[24px] text-primary tracking-[0.4em] font-bold uppercase">
|
||||
{siteData.brand.name}
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-8 lg:gap-16">
|
||||
{siteData.footer.links.map((l) => (
|
||||
<Link
|
||||
key={l.label}
|
||||
className="text-on-surface-variant font-[var(--font-label-caps)] text-xs lg:text-sm hover:text-primary transition-colors"
|
||||
href={l.href}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-[14px] lg:text-[16px] text-on-surface-variant/40 text-center xl:text-right max-w-sm leading-relaxed uppercase tracking-widest">
|
||||
{siteData.footer.copyright}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import HeaderClient from "./HeaderClient";
|
||||
import siteData from "@/app/data/site.json";
|
||||
|
||||
export default function Header() {
|
||||
return <HeaderClient brand={siteData.brand} nav={siteData.nav} />;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type NavItem = { label: string; href: string };
|
||||
|
||||
export default function HeaderClient({
|
||||
brand,
|
||||
nav,
|
||||
}: {
|
||||
brand: { name: string };
|
||||
nav: NavItem[];
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
const isHome = pathname === "/";
|
||||
const navItems = useMemo(() => nav.filter((x) => x.href !== "/"), [nav]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileOpen) return;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [mobileOpen]);
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 w-full z-50 bg-surface/80 backdrop-blur-xl border-b border-white/5">
|
||||
<div className="flex justify-between items-center px-[var(--spacing-gutter)] py-4 lg:py-8 w-full max-w-hd mx-auto">
|
||||
<Link
|
||||
href="/"
|
||||
className="font-[var(--font-display-lg)] text-[20px] sm:text-[22px] lg:text-[36px] text-primary tracking-tighter uppercase font-bold"
|
||||
>
|
||||
{brand.name}
|
||||
</Link>
|
||||
|
||||
<nav className="hidden xl:flex items-center gap-10 2xl:gap-16">
|
||||
<Link
|
||||
className={`font-[var(--font-label-caps)] text-sm ${
|
||||
isHome ? "text-primary border-b-2 border-primary pb-1" : "text-on-surface-variant hover:text-primary"
|
||||
} transition-colors duration-300`}
|
||||
href="/"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
className="text-on-surface-variant font-[var(--font-label-caps)] text-sm hover:text-primary transition-colors duration-300"
|
||||
href={item.href}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-4 lg:gap-10">
|
||||
<span className="hidden sm:block text-on-surface-variant/60 font-[var(--font-label-caps)] text-xs">
|
||||
EN
|
||||
</span>
|
||||
<Link
|
||||
href="/registration"
|
||||
className="hidden sm:inline-flex gold-gradient-bg text-on-primary px-6 lg:px-10 py-3 lg:py-4 font-[var(--font-body-base)] text-sm lg:text-[18px] font-bold uppercase tracking-wider rounded-lg active:scale-95 transition-transform shadow-lg shadow-primary/10"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="xl:hidden text-primary p-2"
|
||||
aria-label="Toggle menu"
|
||||
aria-expanded={mobileOpen}
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-3xl">{mobileOpen ? "close" : "menu"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`fixed inset-0 top-[72px] lg:top-[104px] bg-surface z-40 flex-col p-[var(--spacing-gutter)] gap-8 xl:hidden border-t border-white/5 ${
|
||||
mobileOpen ? "flex" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<Link className="text-2xl font-[var(--font-display-lg)] text-on-surface" href="/" onClick={() => setMobileOpen(false)}>
|
||||
Home
|
||||
</Link>
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
className="text-2xl font-[var(--font-display-lg)] text-on-surface"
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="mt-auto flex flex-col gap-6">
|
||||
<div className="text-on-surface-variant/60 font-[var(--font-label-caps)] text-sm">Language: EN</div>
|
||||
<Link
|
||||
href="/registration"
|
||||
className="gold-gradient-bg text-on-primary w-full py-5 font-[var(--font-body-base)] text-lg font-bold uppercase tracking-wider rounded-lg text-center"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
Register Now
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"hero": {
|
||||
"badgeIcon": "calendar_today",
|
||||
"badgeText": "June 2026 | Ho Chi Minh City, Vietnam",
|
||||
"title": "IPv6 for AI & Data Centre Summit 2026",
|
||||
"subtitle": "Unlocking the Potential of Next-Gen Infrastructure. Join global leaders in redefining ASEAN's digital connectivity for the age of AI.",
|
||||
"primaryCta": { "label": "Register", "href": "/registration" },
|
||||
"secondaryCta": { "label": "View Agenda", "href": "#agenda" }
|
||||
},
|
||||
"vision": {
|
||||
"eyebrow": "VISION & GOALS",
|
||||
"title": "ASEAN's Digital Future",
|
||||
"paragraphs": [
|
||||
"Vietnam and the broader ASEAN region are at a critical juncture in digital transformation. The transition to IPv6 is the fundamental infrastructure for AI growth and massive data centre scaling.",
|
||||
"IPv6 is the backbone for scalable cloud, secure connectivity, and AI-ready networking across the region."
|
||||
],
|
||||
"cards": [
|
||||
{
|
||||
"icon": "hub",
|
||||
"title": "Networking",
|
||||
"description": "Connect with C-level executives, policymakers, and technical leaders across the ecosystem."
|
||||
},
|
||||
{
|
||||
"icon": "insights",
|
||||
"title": "Tech Insights",
|
||||
"description": "Deep dives into AI workloads, hyperscale infrastructure, and next-gen IPv6 operations."
|
||||
},
|
||||
{
|
||||
"icon": "gavel",
|
||||
"title": "Policy",
|
||||
"description": "A forum for national infrastructure standards, governance, and cross-border collaboration."
|
||||
}
|
||||
]
|
||||
},
|
||||
"stats": {
|
||||
"id": "stats",
|
||||
"items": [
|
||||
{ "value": "130+", "label": "Global Speakers" },
|
||||
{ "value": "14", "label": "ASEAN Nations" },
|
||||
{ "value": "50+", "label": "Tech Partners" }
|
||||
]
|
||||
},
|
||||
"agenda": {
|
||||
"id": "agenda",
|
||||
"eyebrow": "SCHEDULE",
|
||||
"title": "Event Agenda",
|
||||
"description": "A strategic overview of the technical and strategic sessions.",
|
||||
"items": [
|
||||
{
|
||||
"order": "01",
|
||||
"time": "08:30",
|
||||
"title": "Opening Ceremony",
|
||||
"description": "Welcome address by MIC Vietnam and regional digital economy leaders."
|
||||
},
|
||||
{
|
||||
"order": "02",
|
||||
"time": "10:00",
|
||||
"title": "AI in Data Centres",
|
||||
"description": "How IPv6 enables hyperscale processing and low-latency fabric."
|
||||
},
|
||||
{
|
||||
"order": "03",
|
||||
"time": "14:00",
|
||||
"title": "Forum: Policy & Scale",
|
||||
"description": "Closed-door discussion for regulators and industry leaders."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "Summit Registration",
|
||||
"subtitle": "Secure your presence at the forefront of IPv6 innovation for AI and Data Centres.",
|
||||
"sections": {
|
||||
"personal": { "title": "Personal Info", "fullName": "Full Name", "phone": "Phone Number", "email": "Business Email" },
|
||||
"professional": { "title": "Professional Info", "company": "Company Name", "jobTitle": "Job Title" },
|
||||
"industry": {
|
||||
"title": "Industry",
|
||||
"label": "Select Industry",
|
||||
"placeholder": "Please choose...",
|
||||
"options": [
|
||||
{ "value": "government", "label": "Government" },
|
||||
{ "value": "telecom", "label": "Telecom" },
|
||||
{ "value": "cloud", "label": "Cloud / Data Centre" },
|
||||
{ "value": "ai", "label": "AI / Software" },
|
||||
{ "value": "others", "label": "Others" }
|
||||
]
|
||||
},
|
||||
"additional": {
|
||||
"title": "Additional Details",
|
||||
"label": "Special requirements or dietary restrictions",
|
||||
"placeholder": "Vegetarian options, accessibility needs, etc."
|
||||
}
|
||||
},
|
||||
"submit": { "label": "Submit Registration", "disclaimer": "By registering, you agree to our Privacy Policy and Terms of Service." }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"brand": {
|
||||
"name": "IPv6 SUMMIT 2026"
|
||||
},
|
||||
"nav": [
|
||||
{ "label": "Home", "href": "/" },
|
||||
{ "label": "Registration", "href": "/registration" },
|
||||
{ "label": "Feedback", "href": "/feedback" },
|
||||
{ "label": "Sponsor", "href": "/sponsor" }
|
||||
],
|
||||
"footer": {
|
||||
"links": [
|
||||
{ "label": "Privacy", "href": "#" },
|
||||
{ "label": "Terms", "href": "#" },
|
||||
{ "label": "Press", "href": "#" },
|
||||
{ "label": "Contact", "href": "#" }
|
||||
],
|
||||
"copyright":
|
||||
"© 2026 IPv6 for AI & Data Centre Summit. Infrastructure for the Future."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* IPV6 Summit design tokens */
|
||||
--color-primary: #c5a059;
|
||||
--color-secondary: #8e6d3d;
|
||||
--color-on-primary: #261900;
|
||||
|
||||
--color-background: #0f0f0f;
|
||||
--color-surface: #0f0f0f;
|
||||
--color-surface-container-low: #141414;
|
||||
--color-surface-container-high: #1f1f1f;
|
||||
--color-surface-container-highest: #292929;
|
||||
|
||||
--color-on-surface: #ffffff;
|
||||
--color-on-surface-variant: #e0e0e0;
|
||||
--color-outline: #c5a059;
|
||||
|
||||
--font-body-base: "Inter", system-ui, sans-serif;
|
||||
--font-display-lg: "Space Grotesk", system-ui, sans-serif;
|
||||
--font-label-caps: "Geist", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
|
||||
--spacing-gutter: clamp(1rem, 4vw, 3.75rem);
|
||||
--spacing-container-max: 1800px;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-on-surface-variant);
|
||||
font-family: var(--font-body-base);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: rgba(224, 224, 224, 0.4);
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 24;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.max-w-hd {
|
||||
max-width: 1920px;
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background: rgba(15, 15, 15, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(197, 160, 89, 0.2);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.glass-panel {
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(197, 160, 89, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.gold-gradient-bg {
|
||||
background: linear-gradient(135deg, #c5a059 0%, #8e6d3d 100%);
|
||||
}
|
||||
|
||||
.hero-glow {
|
||||
background: radial-gradient(circle at 50% 40%, rgba(197, 160, 89, 0.08) 0%, rgba(15, 15, 15, 0) 70%);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.hero-glow {
|
||||
background: radial-gradient(circle at 50% 50%, rgba(197, 160, 89, 0.12) 0%, rgba(15, 15, 15, 0) 80%);
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"hero": {
|
||||
"badge": "Top Ranked Online Education",
|
||||
"title": "Advance Your Career Without Boundaries.",
|
||||
"description": "Access world-class education from anywhere. Flexible schedules, accredited programs, and affordable tuition designed for the modern professional.",
|
||||
"searchPlaceholder": "e.g. Business Administration",
|
||||
"buttonLabel": "Find Program",
|
||||
"image": "https://storage.googleapis.com/uxpilot-auth.appspot.com/d9383b2d27-55d93496971925dee83d.png",
|
||||
"imageAlt": "diverse group of modern adult students studying online with laptops, professional lighting, cinematic, high quality",
|
||||
"floatingBadge": {
|
||||
"icon": "fa-users",
|
||||
"value": "50,000+",
|
||||
"label": "Active Students"
|
||||
}
|
||||
},
|
||||
"quickLinks": [
|
||||
{
|
||||
"icon": "fa-trophy",
|
||||
"title": "Milestones",
|
||||
"description": "Explore our history of academic excellence and major achievements since our founding.",
|
||||
"linkText": "Explore History",
|
||||
"href": "/about/history"
|
||||
},
|
||||
{
|
||||
"icon": "fa-certificate",
|
||||
"title": "Accreditations",
|
||||
"description": "Review our globally recognized credentials ensuring the highest quality of education.",
|
||||
"linkText": "View Credentials",
|
||||
"href": "/about/accreditation"
|
||||
},
|
||||
{
|
||||
"icon": "fa-handshake",
|
||||
"title": "Partnerships",
|
||||
"description": "Discover our network of industry leaders providing career pathways for graduates.",
|
||||
"linkText": "See Partners",
|
||||
"href": "/about/partnerships"
|
||||
},
|
||||
{
|
||||
"icon": "fa-newspaper",
|
||||
"title": "Latest Blog",
|
||||
"description": "Read insights, student success stories, and updates from our academic community.",
|
||||
"linkText": "Read Articles",
|
||||
"href": "/blog"
|
||||
}
|
||||
],
|
||||
"valueProp": {
|
||||
"badge": "Why Choose Us",
|
||||
"title": "Education Engineered for the Modern World",
|
||||
"description": "We believe that quality education should be accessible to everyone, regardless of location or schedule. Our platform delivers an immersive learning experience backed by industry-leading technology.",
|
||||
"features": [
|
||||
{
|
||||
"icon": "fa-laptop-code",
|
||||
"title": "100% Online Flexibility",
|
||||
"description": "Study on your own time with asynchronous classes designed to fit around your work and life commitments."
|
||||
},
|
||||
{
|
||||
"icon": "fa-piggy-bank",
|
||||
"title": "Affordable Tuition",
|
||||
"description": "Graduate with less debt. Our transparent pricing and financial aid options make your degree attainable."
|
||||
},
|
||||
{
|
||||
"icon": "fa-briefcase",
|
||||
"title": "Career-Focused Curriculum",
|
||||
"description": "Programs developed in partnership with industry leaders to ensure you learn the skills employers actually want."
|
||||
}
|
||||
],
|
||||
"stats": [
|
||||
{
|
||||
"value": "94%",
|
||||
"label": "Employment rate within 6 months of graduation",
|
||||
"image": "https://storage.googleapis.com/uxpilot-auth.appspot.com/b1e2e6a5a4-7c815d7e47c7e7471c5e.png",
|
||||
"imageAlt": "student studying late at night looking focused"
|
||||
},
|
||||
{
|
||||
"value": "200+",
|
||||
"label": "Accredited degree and certificate programs",
|
||||
"image": "https://storage.googleapis.com/uxpilot-auth.appspot.com/8e7cd5934f-dc6192e3a13ea24a5312.png",
|
||||
"imageAlt": "graduation cap and diploma abstract professional setup"
|
||||
}
|
||||
]
|
||||
},
|
||||
"programs": {
|
||||
"heading": "Featured Programs",
|
||||
"description": "Discover our most popular career-focused degrees and certificates designed for the modern job market.",
|
||||
"items": [
|
||||
{
|
||||
"category": "Tech & Software",
|
||||
"image": "https://storage.googleapis.com/uxpilot-auth.appspot.com/f6c491e508-064cc521a89dee9776d1.png",
|
||||
"duration": "12-18 Months",
|
||||
"rating": "4.9",
|
||||
"title": "B.S. Computer Science",
|
||||
"description": "Master full-stack development, algorithms, and system design with hands-on projects.",
|
||||
"studentCount": "+1k",
|
||||
"href": "/programmes/bs-cs"
|
||||
},
|
||||
{
|
||||
"category": "Data Analysis",
|
||||
"image": "https://storage.googleapis.com/uxpilot-auth.appspot.com/e17be2a4e6-91247c5d9a4eed4d28a9.png",
|
||||
"duration": "8-12 Months",
|
||||
"rating": "4.8",
|
||||
"title": "Data Analytics Certificate",
|
||||
"description": "Learn SQL, Python, and Tableau to transform complex data into actionable business insights.",
|
||||
"studentCount": "+800",
|
||||
"href": "/programmes/cert-da"
|
||||
},
|
||||
{
|
||||
"category": "Business",
|
||||
"image": "https://storage.googleapis.com/uxpilot-auth.appspot.com/c53c1f7b37-66e95dbe58513f7bce4b.png",
|
||||
"duration": "18-24 Months",
|
||||
"rating": "4.9",
|
||||
"title": "MBA in Leadership",
|
||||
"description": "Develop strategic management skills and leadership qualities for the modern corporate world.",
|
||||
"studentCount": "+2k",
|
||||
"href": "/programmes/mba-db"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requestInfo": {
|
||||
"heading": "Take the Next Step in Your Career.",
|
||||
"description": "Request more information about our programs, tuition, and admissions process. Our advisors are ready to help you plan your future.",
|
||||
"phone": "12345678-GLOBAL-U",
|
||||
"email": " info@lams.ac",
|
||||
"programs": [
|
||||
"Computer Science",
|
||||
"Business Administration",
|
||||
"Data Analytics",
|
||||
"Nursing"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Metadata } from "next";
|
||||
import "../public/assets/css/all.min.css";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"),
|
||||
title: "IPv6 Summit 2026 | Infrastructure for AI",
|
||||
description:
|
||||
"IPv6 for AI & Data Centre Summit 2026 — a premier event for next-gen infrastructure in ASEAN.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
{/* Fonts */}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600;700&family=Geist:wght@400;600&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
{/* Icons */}
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
{/* Favicon */}
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
href="/assets/img/favicon.png"
|
||||
type="image/x-icon"
|
||||
/>
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-[60vh] flex flex-col items-center justify-center py-24 px-6 md:px-12 lg:px-24 bg-uni-grey">
|
||||
<div className="max-w-2xl w-full text-center bg-white p-10 md:p-16 rounded-[32px] ref-card-shadow border border-slate-100">
|
||||
|
||||
{/* Icon */}
|
||||
<div className="w-20 h-20 mx-auto rounded-full bg-uni-orange/10 flex items-center justify-center text-uni-orange mb-8">
|
||||
<i className="fa-solid fa-triangle-exclamation text-4xl"></i>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<h1 className="font-display text-5xl md:text-6xl font-bold text-uni-dark mb-6">404</h1>
|
||||
<h2 className="font-display text-2xl md:text-3xl font-bold text-uni-dark mb-4">Page Not Found</h2>
|
||||
<p className="text-slate-600 text-lg mb-10 leading-relaxed">
|
||||
The page you're looking for could not be found. It may have been moved or deleted.
|
||||
Please check the URL or return to our home page.
|
||||
</p>
|
||||
|
||||
{/* Action Button */}
|
||||
<div className="flex justify-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="px-8 py-4 bg-uni-dark hover:bg-slate-800 text-white rounded-full font-semibold text-lg transition-all flex items-center justify-center gap-2 shadow-lg"
|
||||
>
|
||||
Back to Home <i className="fa-solid fa-arrow-right text-sm"></i>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Converts an EditorJS JSON output (string or object) into an HTML string.
|
||||
* Supports all block types used in the CMS blog editor.
|
||||
*/
|
||||
export function editorjsToHtml(raw: string | object | null | undefined): string {
|
||||
if (!raw) return "";
|
||||
|
||||
let data: { blocks?: Array<{ type: string; data: Record<string, unknown> }> };
|
||||
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
// Not JSON — treat as plain HTML string (legacy content)
|
||||
return raw;
|
||||
}
|
||||
} else {
|
||||
data = raw as typeof data;
|
||||
}
|
||||
|
||||
if (!data || !Array.isArray(data.blocks) || data.blocks.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return data.blocks.map(renderBlock).join("\n");
|
||||
}
|
||||
|
||||
function esc(text: unknown): string {
|
||||
return String(text ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function renderBlock(block: { type: string; data: Record<string, unknown> }): string {
|
||||
const { type, data } = block;
|
||||
|
||||
switch (type) {
|
||||
case "paragraph": {
|
||||
const text = data.text as string ?? "";
|
||||
return `<p>${text}</p>`;
|
||||
}
|
||||
|
||||
case "header": {
|
||||
const level = Number(data.level) || 2;
|
||||
const tag = `h${Math.min(Math.max(level, 1), 6)}`;
|
||||
return `<${tag}>${data.text}</${tag}>`;
|
||||
}
|
||||
|
||||
case "list": {
|
||||
const style = data.style === "ordered" ? "ol" : "ul";
|
||||
const items = (data.items as string[]) ?? [];
|
||||
const lis = items.map((item) => `<li>${item}</li>`).join("\n");
|
||||
return `<${style}>\n${lis}\n</${style}>`;
|
||||
}
|
||||
|
||||
case "checklist": {
|
||||
const items = (data.items as Array<{ text: string; checked: boolean }>) ?? [];
|
||||
const lis = items
|
||||
.map(
|
||||
(item) =>
|
||||
`<li class="editorjs-checklist-item${item.checked ? " checked" : ""}">` +
|
||||
`<span class="editorjs-checkbox">${item.checked ? "✓" : "○"}</span> ${item.text}` +
|
||||
`</li>`
|
||||
)
|
||||
.join("\n");
|
||||
return `<ul class="editorjs-checklist">\n${lis}\n</ul>`;
|
||||
}
|
||||
|
||||
case "image": {
|
||||
const file = (data.file as { url?: string }) ?? {};
|
||||
const url = file.url ?? (data.url as string) ?? "";
|
||||
const caption = (data.caption as string) ?? "";
|
||||
const stretched = data.stretched ? ' class="editorjs-image-stretched"' : "";
|
||||
const withBg = data.withBackground ? ' style="background:#f5f5f5; padding:1rem;"' : "";
|
||||
return (
|
||||
`<figure${withBg}>` +
|
||||
`<img src="${esc(url)}" alt="${esc(caption)}"${stretched} loading="lazy" />` +
|
||||
(caption ? `<figcaption>${caption}</figcaption>` : "") +
|
||||
`</figure>`
|
||||
);
|
||||
}
|
||||
|
||||
case "quote": {
|
||||
const text = data.text as string ?? "";
|
||||
const caption = data.caption as string ?? "";
|
||||
return (
|
||||
`<blockquote class="editorjs-quote">` +
|
||||
`<p>${text}</p>` +
|
||||
(caption ? `<cite>${caption}</cite>` : "") +
|
||||
`</blockquote>`
|
||||
);
|
||||
}
|
||||
|
||||
case "code": {
|
||||
return `<pre><code>${esc(data.code)}</code></pre>`;
|
||||
}
|
||||
|
||||
case "delimiter": {
|
||||
return `<hr class="editorjs-delimiter" />`;
|
||||
}
|
||||
|
||||
case "table": {
|
||||
const content = (data.content as string[][]) ?? [];
|
||||
const withHeadings = data.withHeadings as boolean;
|
||||
const rows = content.map((row, rowIdx) => {
|
||||
const tag = withHeadings && rowIdx === 0 ? "th" : "td";
|
||||
const cells = row.map((cell) => `<${tag}>${cell}</${tag}>`).join("");
|
||||
return `<tr>${cells}</tr>`;
|
||||
});
|
||||
return `<table class="editorjs-table">\n${rows.join("\n")}\n</table>`;
|
||||
}
|
||||
|
||||
case "embed": {
|
||||
const service = (data.service as string ?? "").toLowerCase();
|
||||
const embedUrl = (data.embed as string) ?? "";
|
||||
const caption = (data.caption as string) ?? "";
|
||||
const width = (data.width as number) ?? 560;
|
||||
const height = (data.height as number) ?? 315;
|
||||
|
||||
// EditorJS Embed stores the ready-to-use iframe src in data.embed
|
||||
let iframeSrc = esc(embedUrl);
|
||||
|
||||
// Fallback: if data.embed is the watch URL, convert it
|
||||
if (service === "youtube" && iframeSrc.includes("watch?v=")) {
|
||||
iframeSrc = iframeSrc.replace("watch?v=", "embed/").split("&")[0];
|
||||
} else if (service === "vimeo" && !iframeSrc.includes("player.vimeo.com")) {
|
||||
const vimeoId = iframeSrc.split("/").pop() ?? "";
|
||||
iframeSrc = `https://player.vimeo.com/video/${vimeoId}`;
|
||||
}
|
||||
|
||||
return (
|
||||
`<figure class="editorjs-embed">` +
|
||||
`<iframe src="${iframeSrc}" width="${width}" height="${height}" frameborder="0" allowfullscreen loading="lazy"></iframe>` +
|
||||
(caption ? `<figcaption>${caption}</figcaption>` : "") +
|
||||
`</figure>`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
default:
|
||||
// Unknown block — skip silently
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export const imageUrl = (path?: string) => {
|
||||
// Không có ảnh → ảnh mặc định
|
||||
if (!path) return "/_images/default.jpg";
|
||||
|
||||
// Đã là full URL
|
||||
if (path.startsWith("http")) return path;
|
||||
|
||||
const base = (
|
||||
process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"
|
||||
).replace(/\/$/, "");
|
||||
|
||||
return `${base}/${path.replace(/^\//, "")}`;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,357 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="dark" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@600;700&family=Geist:wght@600&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"surface-tint": "#e9c176",
|
||||
"on-secondary-container": "#d9b27c",
|
||||
"surface-container-low": "#1c1b1b",
|
||||
"primary-fixed-dim": "#e9c176",
|
||||
"primary": "#e9c176",
|
||||
"on-background": "#e5e2e1",
|
||||
"surface-bright": "#3a3939",
|
||||
"on-tertiary-fixed": "#1c1b20",
|
||||
"tertiary-fixed-dim": "#c9c5cc",
|
||||
"surface-container": "#201f1f",
|
||||
"on-secondary-fixed-variant": "#5d4216",
|
||||
"outline-variant": "#4e4639",
|
||||
"on-secondary-fixed": "#291800",
|
||||
"secondary-container": "#604418",
|
||||
"secondary": "#e9c088",
|
||||
"surface-container-lowest": "#0e0e0e",
|
||||
"error": "#ffb4ab",
|
||||
"error-container": "#93000a",
|
||||
"on-error-container": "#ffdad6",
|
||||
"inverse-primary": "#775a19",
|
||||
"tertiary-container": "#a7a4ab",
|
||||
"on-tertiary-fixed-variant": "#48464c",
|
||||
"on-surface": "#e5e2e1",
|
||||
"inverse-on-surface": "#313030",
|
||||
"primary-container": "#c5a059",
|
||||
"surface-container-high": "#2a2a2a",
|
||||
"primary-fixed": "#ffdea5",
|
||||
"secondary-fixed-dim": "#e9c088",
|
||||
"on-tertiary": "#313035",
|
||||
"surface": "#131313",
|
||||
"secondary-fixed": "#ffddb2",
|
||||
"background": "#131313",
|
||||
"surface-container-highest": "#353534",
|
||||
"tertiary": "#c9c5cc",
|
||||
"on-primary": "#412d00",
|
||||
"on-secondary": "#442b02",
|
||||
"inverse-surface": "#e5e2e1",
|
||||
"on-primary-container": "#4e3700",
|
||||
"tertiary-fixed": "#e5e1e8",
|
||||
"outline": "#9a8f80",
|
||||
"on-surface-variant": "#d1c5b4",
|
||||
"on-primary-fixed": "#261900",
|
||||
"on-tertiary-container": "#3c3a40",
|
||||
"on-primary-fixed-variant": "#5d4201",
|
||||
"surface-dim": "#131313",
|
||||
"surface-variant": "#353534",
|
||||
"on-error": "#690005"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.125rem",
|
||||
"lg": "0.25rem",
|
||||
"xl": "0.5rem",
|
||||
"full": "0.75rem"
|
||||
},
|
||||
"spacing": {
|
||||
"stack-lg": "32px",
|
||||
"unit": "4px",
|
||||
"stack-sm": "8px",
|
||||
"margin-safe": "40px",
|
||||
"stack-md": "16px",
|
||||
"section-gap": "80px",
|
||||
"gutter": "24px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"body-base": ["Inter"],
|
||||
"display-lg": ["Space Grotesk"],
|
||||
"title-sm": ["Inter"],
|
||||
"label-caps": ["Geist"],
|
||||
"headline-md": ["Space Grotesk"]
|
||||
},
|
||||
"fontSize": {
|
||||
"body-base": ["16px", {"lineHeight": "1.6", "letterSpacing": "0", "fontWeight": "400"}],
|
||||
"display-lg": ["48px", {"lineHeight": "1.1", "letterSpacing": "-0.02em", "fontWeight": "700"}],
|
||||
"title-sm": ["20px", {"lineHeight": "1.4", "letterSpacing": "0.02em", "fontWeight": "600"}],
|
||||
"label-caps": ["12px", {"lineHeight": "1", "letterSpacing": "0.1em", "fontWeight": "600"}],
|
||||
"headline-md": ["32px", {"lineHeight": "1.2", "letterSpacing": "-0.01em", "fontWeight": "600"}]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.gold-gradient-bg {
|
||||
background: linear-gradient(135deg, #e9c176 0%, #775a19 100%);
|
||||
}
|
||||
.hero-glow {
|
||||
background: radial-gradient(circle at 50% 50%, rgba(233, 193, 118, 0.15) 0%, rgba(15, 15, 15, 0) 70%);
|
||||
}
|
||||
.glass-panel {
|
||||
background: rgba(26, 25, 30, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.input-focus-gold:focus {
|
||||
outline: none;
|
||||
border-color: #e9c176;
|
||||
box-shadow: 0 0 0 1px #e9c176;
|
||||
}
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-on-background font-body-base overflow-x-hidden">
|
||||
<!-- TopNavBar -->
|
||||
<header class="fixed top-0 w-full z-50 bg-surface-glass backdrop-blur-md border-b border-border-subtle">
|
||||
<div class="flex justify-between items-center w-full px-gutter py-4 max-w-container-max mx-auto">
|
||||
<div class="font-h1 text-[24px] font-bold text-on-surface tracking-tighter uppercase">
|
||||
<span class="text-primary">IPv6 SUMMIT 2026</span>
|
||||
</div>
|
||||
<nav class="hidden md:flex gap-stack-md items-center">
|
||||
<a class="text-on-surface-variant font-medium hover:text-secondary-fixed transition-colors duration-300 font-label-caps text-label-caps uppercase" href="#">Agenda</a>
|
||||
<a class="text-on-surface-variant font-medium hover:text-secondary-fixed transition-colors duration-300 font-label-caps text-label-caps uppercase" href="#">Speakers</a>
|
||||
<a class="text-on-surface-variant font-medium hover:text-secondary-fixed transition-colors duration-300 font-label-caps text-label-caps uppercase" href="#">Stats</a>
|
||||
<a class="text-on-surface-variant font-medium hover:text-secondary-fixed transition-colors duration-300 font-label-caps text-label-caps uppercase" href="#">Venue</a>
|
||||
</nav>
|
||||
<div class="flex gap-stack-sm items-center">
|
||||
<span class="text-on-surface-variant font-label-caps text-label-caps mr-4 cursor-pointer"><br/></span>
|
||||
<button class="gold-gradient-bg text-on-primary px-6 py-2 rounded-lg font-label-caps text-label-caps uppercase hover:scale-95 transition-all duration-200">Register</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Main Content & Hero Area -->
|
||||
<main class="relative min-h-screen pt-32 pb-section-gap">
|
||||
<!-- Background Hero Glow -->
|
||||
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-full h-[800px] hero-glow pointer-events-none -z-10"></div>
|
||||
<div class="max-w-[800px] mx-auto px-gutter">
|
||||
<!-- Header Title -->
|
||||
<div class="text-center mb-section-gap">
|
||||
<h1 class="font-display-lg text-[72px] leading-tight font-bold tracking-tighter mb-4 text-primary">Event Feedback</h1>
|
||||
<p class="text-on-surface-variant text-title-sm font-title-sm max-w-2xl mx-auto">
|
||||
Your insights drive the future of IPv6 infrastructure. Help us shape the 2027 experience.
|
||||
<br/><br/></p>
|
||||
</div>
|
||||
<!-- Feedback Form -->
|
||||
<form class="glass-panel p-stack-lg rounded-xl space-y-section-gap relative overflow-hidden">
|
||||
<!-- Section 1: General Satisfaction -->
|
||||
<section class="space-y-stack-lg">
|
||||
<div class="border-l-2 border-primary pl-4">
|
||||
<h2 class="font-headline-md text-headline-md text-on-surface mb-1 uppercase tracking-tight">1. GENERAL SATISFACTION</h2>
|
||||
</div>
|
||||
<div class="space-y-stack-md">
|
||||
<label class="block font-label-caps text-label-caps text-primary uppercase">Overall Event Rating</label>
|
||||
<div class="flex justify-between items-center gap-stack-sm max-w-md">
|
||||
<span class="text-body-sm opacity-50">Poor</span>
|
||||
<div class="flex gap-4">
|
||||
<label class="cursor-pointer group">Overall Event Rating</label>
|
||||
<label class="cursor-pointer group">
|
||||
<input class="hidden peer" name="event-rating" type="radio" value="2"/>
|
||||
<div class="w-12 h-12 flex items-center justify-center border border-white/10 rounded peer-checked:bg-primary peer-checked:text-on-primary hover:border-primary transition-all">2</div>
|
||||
</label>
|
||||
<label class="cursor-pointer group">
|
||||
<input class="hidden peer" name="event-rating" type="radio" value="3"/>
|
||||
<div class="w-12 h-12 flex items-center justify-center border border-white/10 rounded peer-checked:bg-primary peer-checked:text-on-primary hover:border-primary transition-all">3</div>
|
||||
</label>
|
||||
<label class="cursor-pointer group">
|
||||
<input class="hidden peer" name="event-rating" type="radio" value="4"/>
|
||||
<div class="w-12 h-12 flex items-center justify-center border border-white/10 rounded peer-checked:bg-primary peer-checked:text-on-primary hover:border-primary transition-all">4</div>
|
||||
</label>
|
||||
<label class="cursor-pointer group">
|
||||
<input class="hidden peer" name="event-rating" type="radio" value="5"/>
|
||||
<div class="w-12 h-12 flex items-center justify-center border border-white/10 rounded peer-checked:bg-primary peer-checked:text-on-primary hover:border-primary transition-all">5</div>
|
||||
</label>
|
||||
</div>
|
||||
<span class="text-body-sm opacity-50">Excellent</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-stack-md">
|
||||
<label class="block font-label-caps text-label-caps text-primary uppercase">Overall Event Rating</label>
|
||||
<div class="flex justify-between items-center gap-stack-sm max-w-md">
|
||||
<span class="text-body-sm opacity-50">Dissatisfied</span>
|
||||
<div class="flex gap-4">
|
||||
<label class="cursor-pointer group">Overall Event Rating</label>
|
||||
<label class="cursor-pointer group">
|
||||
<input class="hidden peer" name="venue-rating" type="radio" value="2"/>
|
||||
<div class="w-12 h-12 flex items-center justify-center border border-white/10 rounded peer-checked:bg-primary peer-checked:text-on-primary hover:border-primary transition-all">2</div>
|
||||
</label>
|
||||
<label class="cursor-pointer group">
|
||||
<input class="hidden peer" name="venue-rating" type="radio" value="3"/>
|
||||
<div class="w-12 h-12 flex items-center justify-center border border-white/10 rounded peer-checked:bg-primary peer-checked:text-on-primary hover:border-primary transition-all">3</div>
|
||||
</label>
|
||||
<label class="cursor-pointer group">
|
||||
<input class="hidden peer" name="venue-rating" type="radio" value="4"/>
|
||||
<div class="w-12 h-12 flex items-center justify-center border border-white/10 rounded peer-checked:bg-primary peer-checked:text-on-primary hover:border-primary transition-all">4</div>
|
||||
</label>
|
||||
<label class="cursor-pointer group">
|
||||
<input class="hidden peer" name="venue-rating" type="radio" value="5"/>
|
||||
<div class="w-12 h-12 flex items-center justify-center border border-white/10 rounded peer-checked:bg-primary peer-checked:text-on-primary hover:border-primary transition-all">5</div>
|
||||
</label>
|
||||
</div>
|
||||
<span class="text-body-sm opacity-50">Very Satisfied</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Section 2: Content Evaluation -->
|
||||
<section class="space-y-stack-lg">
|
||||
<div class="border-l-2 border-primary pl-4">
|
||||
<h2 class="font-headline-md text-headline-md text-on-surface mb-1 uppercase tracking-tight">2. CONTENT EVALUATION</h2>
|
||||
</div>
|
||||
<div class="space-y-stack-md">
|
||||
<label class="block font-label-caps text-label-caps text-primary uppercase">Most valuable session </label>
|
||||
<select class="w-full bg-surface-container-low border border-white/10 rounded px-4 py-3 text-on-surface input-focus-gold appearance-none"><option value="">Select a session</option><option value="ai">AI in Data Centres</option><option value="green">Green Tech</option><option value="cyber">Cybersecurity</option><option value="policy">Policy & Scale</option><option value="local">Local Partner Session</option></select>
|
||||
</div>
|
||||
<div class="space-y-stack-md">
|
||||
<label class="block font-label-caps text-label-caps text-primary uppercase">Keynote Speaker Rating </label>
|
||||
<div class="flex justify-between items-center gap-stack-sm max-w-md">
|
||||
<span class="text-body-sm opacity-50">Needs Improvement</span>
|
||||
<div class="flex gap-4">
|
||||
<label class="cursor-pointer group">
|
||||
<input class="hidden peer" name="speaker-rating" type="radio" value="1"/>
|
||||
<div class="w-12 h-12 flex items-center justify-center border border-white/10 rounded peer-checked:bg-primary peer-checked:text-on-primary hover:border-primary transition-all">1</div>
|
||||
</label>
|
||||
<label class="cursor-pointer group">
|
||||
<input class="hidden peer" name="speaker-rating" type="radio" value="2"/>
|
||||
<div class="w-12 h-12 flex items-center justify-center border border-white/10 rounded peer-checked:bg-primary peer-checked:text-on-primary hover:border-primary transition-all">2</div>
|
||||
</label>
|
||||
<label class="cursor-pointer group">
|
||||
<input class="hidden peer" name="speaker-rating" type="radio" value="3"/>
|
||||
<div class="w-12 h-12 flex items-center justify-center border border-white/10 rounded peer-checked:bg-primary peer-checked:text-on-primary hover:border-primary transition-all">3</div>
|
||||
</label>
|
||||
<label class="cursor-pointer group">
|
||||
<input class="hidden peer" name="speaker-rating" type="radio" value="4"/>
|
||||
<div class="w-12 h-12 flex items-center justify-center border border-white/10 rounded peer-checked:bg-primary peer-checked:text-on-primary hover:border-primary transition-all">4</div>
|
||||
</label>
|
||||
<label class="cursor-pointer group">
|
||||
<input class="hidden peer" name="speaker-rating" type="radio" value="5"/>
|
||||
<div class="w-12 h-12 flex items-center justify-center border border-white/10 rounded peer-checked:bg-primary peer-checked:text-on-primary hover:border-primary transition-all">5</div>
|
||||
</label>
|
||||
</div>
|
||||
<span class="text-body-sm opacity-50">Exceptional</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Section 3: Qualitative Feedback -->
|
||||
<section class="space-y-stack-lg">
|
||||
<div class="border-l-2 border-primary pl-4">
|
||||
<h2 class="font-headline-md text-headline-md text-on-surface mb-1 uppercase tracking-tight">3. Qualitative Feedback</h2>
|
||||
</div>
|
||||
<div class="space-y-stack-md">
|
||||
<label class="block font-label-caps text-label-caps text-primary uppercase">What can we improve for 2027? </label>
|
||||
<textarea class="w-full bg-surface-container-low border border-white/10 rounded px-4 py-3 text-on-surface input-focus-gold resize-none" placeholder="Your suggestions for next year..." rows="4"></textarea>
|
||||
</div>
|
||||
<div class="space-y-stack-md">
|
||||
<label class="block font-label-caps text-label-caps text-primary uppercase">General Comments </label>
|
||||
<textarea class="w-full bg-surface-container-low border border-white/10 rounded px-4 py-3 text-on-surface input-focus-gold resize-none" placeholder="Any additional thoughts..." rows="3"></textarea>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Submit Button Area -->
|
||||
<!-- Section 4: Industry & Technical Insights -->
|
||||
<section class="space-y-stack-lg">
|
||||
<div class="border-l-2 border-primary pl-4">
|
||||
<h2 class="font-headline-md text-headline-md text-on-surface mb-1 uppercase tracking-tight">4. INDUSTRY & TECHNICAL INSIGHTS</h2>
|
||||
</div>
|
||||
<div class="space-y-stack-md">
|
||||
<label class="block font-label-caps text-label-caps text-primary uppercase">IPv6 Readiness</label>
|
||||
<p class="text-on-surface-variant text-sm mb-2">Your organization's current IPv6 deployment level</p>
|
||||
<div class="flex justify-between items-center gap-stack-sm max-w-md">
|
||||
<span class="text-body-sm opacity-50">1</span>
|
||||
<div class="flex gap-4">
|
||||
<label class="cursor-pointer group">IPv6 Readiness</label>
|
||||
<label class="cursor-pointer group">IPv6 Readiness</label>
|
||||
<label class="cursor-pointer group">IPv6 Readiness</label>
|
||||
<label class="cursor-pointer group">IPv6 Readiness</label>
|
||||
<label class="cursor-pointer group">IPv6 Readiness</label>
|
||||
</div>
|
||||
<span class="text-body-sm opacity-50">5</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-stack-md">
|
||||
<label class="block font-label-caps text-label-caps text-primary uppercase">Infrastructure Challenges</label>
|
||||
<p class="text-on-surface-variant text-sm mb-2">What is the biggest challenge for your AI infrastructure?</p>
|
||||
<select class="w-full bg-surface-container-low border border-white/10 rounded px-4 py-3 text-on-surface input-focus-gold appearance-none"><option value="">Select a challenge</option><option value="budget">Budget</option><option value="technical">Technical Expertise</option><option value="policy">Policy/Regulatory</option><option value="workforce">Workforce</option><option value="hardware">Hardware Availability</option></select>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Section 5: Networking & Future Cooperation -->
|
||||
<section class="space-y-stack-lg">
|
||||
<div class="border-l-2 border-primary pl-4">
|
||||
<h2 class="font-headline-md text-headline-md text-on-surface mb-1 uppercase tracking-tight">5. NETWORKING & FUTURE COOPERATION</h2>
|
||||
</div>
|
||||
<div class="space-y-stack-md">
|
||||
<label class="block font-label-caps text-label-caps text-primary uppercase">Speaker Connection</label>
|
||||
<p class="text-on-surface-variant text-sm mb-2">Are there any specific speakers or companies you wish to be introduced to?</p>
|
||||
<input class="w-full bg-surface-container-low border border-white/10 rounded px-4 py-3 text-on-surface input-focus-gold" placeholder="Name of speaker or company..." type="text"/>
|
||||
</div>
|
||||
<div class="space-y-stack-md">
|
||||
<label class="block font-label-caps text-label-caps text-primary uppercase">Collaboration Interests</label>
|
||||
<p class="text-on-surface-variant text-sm mb-2">I am interested in:</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer group">Collaboration Interests</label>
|
||||
<label class="flex items-center gap-3 cursor-pointer group">Collaboration Interests</label>
|
||||
<label class="flex items-center gap-3 cursor-pointer group">Collaboration Interests</label>
|
||||
<label class="flex items-center gap-3 cursor-pointer group">Collaboration Interests</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Section 6: Logistics Feedback -->
|
||||
<section class="space-y-stack-lg">
|
||||
<div class="border-l-2 border-primary pl-4">
|
||||
<h2 class="font-headline-md text-headline-md text-on-surface mb-1 uppercase tracking-tight">6. LOGISTICS FEEDBACK</h2>
|
||||
</div>
|
||||
<div class="space-y-stack-md">
|
||||
<label class="block font-label-caps text-label-caps text-primary uppercase">Venue Rating</label>
|
||||
<p class="text-on-surface-variant text-sm mb-2">Venue Facilities & Location (Ho Chi Minh City)</p>
|
||||
<div class="flex justify-between items-center gap-stack-sm max-w-md">
|
||||
<span class="text-body-sm opacity-50">Poor</span>
|
||||
<div class="flex gap-4">
|
||||
<label class="cursor-pointer group">Venue Rating</label>
|
||||
<label class="cursor-pointer group">Venue Rating</label>
|
||||
<label class="cursor-pointer group">Venue Rating</label>
|
||||
<label class="cursor-pointer group">Venue Rating</label>
|
||||
<label class="cursor-pointer group">Venue Rating</label>
|
||||
</div>
|
||||
<span class="text-body-sm opacity-50">Excellent</span>
|
||||
</div>
|
||||
</div>
|
||||
</section><div class="pt-stack-lg border-t border-white/5 flex flex-col items-center gap-4">
|
||||
<button class="gold-gradient-bg text-on-primary w-full md:w-auto px-12 py-4 rounded-lg font-headline-md text-[18px] uppercase tracking-wider hover:shadow-[0_0_20px_rgba(233,193,118,0.4)] transition-all duration-300 transform active:scale-95" type="submit">SUBMIT FEEDBACK</button>
|
||||
<p class="text-on-surface-variant font-label-caps text-[10px] opacity-60 uppercase text-center">
|
||||
Thank you for your valuable contribution to the IPv6 ecosystem.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
<!-- Footer -->
|
||||
<footer class="w-full block border-t border-border-subtle bg-surface-container-lowest">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center px-gutter py-12 max-w-container-max mx-auto gap-stack-md">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-h2 text-h2 text-primary uppercase font-bold tracking-tighter">IPv6 SUMMIT 2026</div>
|
||||
<p class="font-body-sm text-body-sm text-text-slate opacity-70">
|
||||
© 2026 IPv6 for AI & Data Centre Summit. Infrastructure for the Future.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-stack-md justify-center">
|
||||
<a class="text-text-slate font-label-caps text-label-caps uppercase hover:text-accent-orange transition-opacity duration-300" href="#">Privacy </a>
|
||||
<a class="text-text-slate font-label-caps text-label-caps uppercase hover:text-accent-orange transition-opacity duration-300" href="#">Terms </a>
|
||||
<a class="text-text-slate font-label-caps text-label-caps uppercase hover:text-accent-orange transition-opacity duration-300" href="#">Contact </a>
|
||||
<a class="text-text-slate font-label-caps text-label-caps uppercase hover:text-accent-orange transition-opacity duration-300" href="#">Sponsorship </a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body></html>
|
||||
@@ -0,0 +1,290 @@
|
||||
# Kế hoạch dự án IPV6 (Next.js App Router) — Tạo mới & Chuyển đổi từ HTML
|
||||
|
||||
## 1) Mục tiêu & phạm vi
|
||||
|
||||
- **Mục tiêu**: Tạo project frontend mới tên **IPV6** dựa trên “format sẵn” của codebase Next.js hiện tại, chuyển các trang HTML tĩnh sang Next.js (TypeScript/TSX), tách component, tách static data (JSON), và chuẩn hoá routing theo nghiệp vụ doanh nghiệp.
|
||||
- **Phạm vi**: Frontend בלבד (chưa làm backend). Các API gọi ra ngoài/CMS (nếu có trong codebase cũ) sẽ **loại bỏ hoặc chuyển thành static JSON** cho đúng scope.
|
||||
- **Yêu cầu chung**:
|
||||
- **Ngôn ngữ UI mặc định**: English (có thể để song ngữ EN/VI như UI toggle, nhưng content/label phải chuẩn hoá).
|
||||
- **Design system thống nhất**: màu sắc, font chữ, spacing, width/height chuẩn (không lệch giữa trang).
|
||||
- **Responsive**: hiển thị tốt trên desktop/tablet/mobile, kiểm tra layout với các breakpoint phổ biến.
|
||||
|
||||
## 2) Hiện trạng codebase (để bám “format sẵn”)
|
||||
|
||||
- Đang dùng **Next.js App Router**: có thư mục `app/`.
|
||||
- Có layout sẵn `app/layout.tsx` đang import CSS global và render `Header` + `Footer`.
|
||||
- Có nhiều phần liên quan LAMS (metadata, service/api, pages/route cũ…).
|
||||
- Các file HTML nguồn cho IPV6 đang nằm ở root:
|
||||
- `test.html` (Trang chủ)
|
||||
- `registration.html` (Form đăng ký)
|
||||
- `feedback.html` (Form feedback)
|
||||
- `sponsor.html` (Kêu gọi đóng góp)
|
||||
- `admin.html` (Trang quản lý request — nội bộ coder)
|
||||
|
||||
## 3) Outcome mong muốn (cấu trúc thư mục đề xuất)
|
||||
|
||||
> Mục tiêu: rõ ràng, tách bạch component/page/data/styles; dễ maintain; đúng App Router.
|
||||
|
||||
Đề xuất skeleton (có thể điều chỉnh theo format hiện tại):
|
||||
|
||||
- `app/`
|
||||
- `(site)/`
|
||||
- `layout.tsx` (site shell: Header/Footer dùng cho public pages)
|
||||
- `page.tsx` (**Home** từ `test.html`)
|
||||
- `registration/page.tsx`
|
||||
- `feedback/page.tsx`
|
||||
- `sponsor/page.tsx`
|
||||
- `(internal)/admin/`
|
||||
- `page.tsx` (**Admin** từ `admin.html`, route group để tách nội bộ)
|
||||
- `globals.css` (CSS global/tokens)
|
||||
- `not-found.tsx` (tuỳ chọn)
|
||||
- `app/components/`
|
||||
- `layout/` (`Header`, `Footer`, `Container`, `Section`, `Nav`, …)
|
||||
- `home/` (các section của home)
|
||||
- `forms/` (shared form fields/validation UI)
|
||||
- `ui/` (Button, Input, Card, Modal, Badge…)
|
||||
- `app/data/` (static JSON)
|
||||
- `site.json` (brand, links, footer)
|
||||
- `home.json` (hero, agenda, speakers, stats, venue, …)
|
||||
- `sponsor.json`
|
||||
- `registration.json` (copy text, options)
|
||||
- `feedback.json`
|
||||
- `admin.json` (cột bảng, status mapping…)
|
||||
- `public/`
|
||||
- `assets/` (img/icons/fonts nếu có)
|
||||
|
||||
## 4) Quy ước routing (đảm bảo nghiệp vụ doanh nghiệp)
|
||||
|
||||
### 4.1 Public pages (khách truy cập)
|
||||
|
||||
- `/` → **Home** (từ `test.html`)
|
||||
- `/registration` → **Registration form** (từ `registration.html`)
|
||||
- `/feedback` → **Feedback form** (từ `feedback.html`)
|
||||
- `/sponsor` → **Sponsor/Donate** (từ `sponsor.html`)
|
||||
|
||||
### 4.2 Internal pages (chỉ coder nội bộ)
|
||||
|
||||
- `/(internal)/admin` → **Admin request management** (từ `admin.html`)
|
||||
- Yêu cầu “chỉ nội bộ”: vì không có backend, sẽ dùng **cơ chế chặn tối thiểu** (xem mục 9).
|
||||
|
||||
## 5) Chuyển đổi Component (HTML → TSX)
|
||||
|
||||
### 5.1 Nguyên tắc tách component
|
||||
|
||||
- Tách theo **layout shell** (Header/Footer) và theo **section** của từng page.
|
||||
- Mỗi component:
|
||||
- Props typed (TypeScript).
|
||||
- Không hardcode text/data nếu có thể đưa vào JSON.
|
||||
- Không nhúng `<script>` DOM manipulation kiểu HTML (sẽ chuyển sang state + event trong React).
|
||||
|
||||
### 5.2 Trang chủ — `test.html`
|
||||
|
||||
**Yêu cầu đặc biệt**: “Tách ra trang home riêng, header và footer có components được tách riêng”.
|
||||
|
||||
- Tạo `app/(site)/page.tsx` làm Home.
|
||||
- Tách Header/Footer:
|
||||
- `app/components/layout/Header/*`
|
||||
- `app/components/layout/Footer/*`
|
||||
- Tách các section của Home (đề xuất theo nội dung `test.html`):
|
||||
- `HeroSection`
|
||||
- `VisionGoalsSection`
|
||||
- `StatsSection`
|
||||
- `AgendaSection`
|
||||
- `StakeholdersSection`
|
||||
- `RegistrationCTASection` (nếu Home có form/CTA)
|
||||
- (Nếu có `Speakers`, `Venue` trong HTML thì tách tương tự)
|
||||
- Thay logic mobile menu:
|
||||
- HTML đang dùng script toggle class `hidden` → chuyển sang `useState` và className theo state.
|
||||
- Chuẩn hoá anchor navigation:
|
||||
- Link `#agenda`, `#stats`, … giữ nguyên trong trang Home, đảm bảo scroll behavior trong CSS global.
|
||||
|
||||
### 5.3 Trang Registration — `registration.html`
|
||||
|
||||
- Tạo `app/(site)/registration/page.tsx`
|
||||
- Tách:
|
||||
- `RegistrationForm` (client component)
|
||||
- Shared inputs: `TextField`, `SelectField`, `TextareaField`, `PhoneField`
|
||||
- Form scope frontend-only:
|
||||
- Submit tạm: `console.log` + toast/alert (hoặc disable submit) tuỳ yêu cầu.
|
||||
- Chuẩn bị shape data để sau này nối backend.
|
||||
|
||||
### 5.4 Trang Feedback — `feedback.html`
|
||||
|
||||
- Tạo `app/(site)/feedback/page.tsx`
|
||||
- Tách `FeedbackForm` tương tự Registration.
|
||||
|
||||
### 5.5 Trang Sponsor — `sponsor.html`
|
||||
|
||||
- Tạo `app/(site)/sponsor/page.tsx`
|
||||
- Tách:
|
||||
- `SponsorHero`
|
||||
- `SponsorPackages`
|
||||
- `SponsorCTA`
|
||||
- `SponsorFAQ` (nếu có)
|
||||
|
||||
### 5.6 Trang Admin — `admin.html` (folder riêng)
|
||||
|
||||
- Tạo `app/(internal)/admin/page.tsx`
|
||||
- Tách:
|
||||
- `RequestsTable`
|
||||
- `RequestFilters`
|
||||
- `RequestDetailDrawer` (tuỳ)
|
||||
- Vì chưa backend:
|
||||
- Data bảng lấy từ `app/data/admin.json` (static).
|
||||
- Chuẩn hoá model “Request”: id, createdAt, name, email, company, status…
|
||||
|
||||
## 6) JSON hoá static data (tách data ra file riêng)
|
||||
|
||||
### 6.1 Nguyên tắc
|
||||
|
||||
- Mọi text/cấu hình lặp lại (nav items, footer links, section headings, stats items, agenda items…) → đưa vào JSON.
|
||||
- JSON đặt trong `app/data/` (hoặc `src/data/` nếu codebase đang dùng `src/`).
|
||||
- Page/Component đọc JSON theo hướng server component:
|
||||
- Page (server) import JSON → pass props xuống components.
|
||||
- Form components là client, chỉ nhận props cần thiết.
|
||||
|
||||
### 6.2 Danh mục data đề xuất
|
||||
|
||||
- `site.json`: brandName, navLinks, footerLinks, social, legal, languages…
|
||||
- `home.json`: hero, vision, stats, agenda, stakeholders, venue, contact…
|
||||
- `registration.json`: copy + options.
|
||||
- `feedback.json`: copy + categories.
|
||||
- `sponsor.json`: tiers/packages + benefits.
|
||||
- `admin.json`: columns + seed rows + status label/color mapping.
|
||||
|
||||
## 7) Styling — CSS chung & thống nhất design system
|
||||
|
||||
### 7.1 Quyết định kỹ thuật (theo codebase hiện tại)
|
||||
|
||||
Codebase hiện có:
|
||||
- Đang import `./globals.css` và có pipeline SCSS build sang `public/assets/css/main.css`.
|
||||
- Có `tailwindcss` trong devDependencies, nhưng HTML nguồn (`test.html`) đang dùng **Tailwind CDN** + inline `<style>`.
|
||||
|
||||
Kế hoạch:
|
||||
- **Không dùng Tailwind CDN trong Next.js** (loại bỏ script CDN).
|
||||
- Chọn 1 hướng (ưu tiên nhất quán):
|
||||
- **Hướng A (khuyến nghị)**: cấu hình Tailwind nội bộ (tailwind.config + postcss) rồi chuyển class từ HTML qua TSX giữ nguyên nhiều utility class, đồng thời đưa token màu/font vào tailwind theme.
|
||||
- **Hướng B**: giữ SCSS/CSS thuần, chuyển utility class sang class semantic (tốn công hơn).
|
||||
|
||||
> Chọn A sẽ giúp chuyển đổi nhanh nhất vì `test.html` đã viết theo Tailwind.
|
||||
|
||||
### 7.2 Design tokens cần chuẩn hoá
|
||||
|
||||
- **Colors**: primary (gold), background (dark), surface containers, on-surface… (đang có trong `test.html` tailwind config).
|
||||
- **Typography**: font families (Space Grotesk, Inter, JetBrains Mono), size scale (fluid typography).
|
||||
- **Layout**: container max width (HD 1920/1800), gutter responsive.
|
||||
- **Border radius / shadows**: thống nhất.
|
||||
|
||||
### 7.3 Checklist responsive
|
||||
|
||||
- Breakpoints tối thiểu: 360, 390, 414, 768, 1024, 1280, 1536, 1920.
|
||||
- Kiểm tra:
|
||||
- Header: menu desktop vs mobile drawer, body scroll lock.
|
||||
- Hero: text không tràn, CTA stack đúng.
|
||||
- Grid sections: số cột thay đổi đúng.
|
||||
- Form: label/input spacing, focus state, error state.
|
||||
|
||||
## 8) Routing & layout composition
|
||||
|
||||
### 8.1 Layout public
|
||||
|
||||
- Dùng `app/(site)/layout.tsx` để wrap Header/Footer cho toàn bộ public pages.
|
||||
- `app/layout.tsx` giữ nhiệm vụ root (html/head, global css, metadata base).
|
||||
|
||||
### 8.2 Layout internal
|
||||
|
||||
- `app/(internal)/layout.tsx` (tuỳ) để internal khác theme nếu cần (ví dụ tối giản, ưu tiên bảng).
|
||||
|
||||
## 9) Bảo vệ trang Admin (nội bộ coder) khi chưa có backend
|
||||
|
||||
Vì chưa backend, không thể “đăng nhập chuẩn” theo doanh nghiệp. Kế hoạch chặn tối thiểu:
|
||||
|
||||
- **Phương án 1 (khuyến nghị)**: `middleware.ts` + Basic Auth (HTTP) dựa trên env server
|
||||
- `ADMIN_BASIC_AUTH_USER`, `ADMIN_BASIC_AUTH_PASS`
|
||||
- Chỉ áp cho path `/admin`
|
||||
- **Phương án 2**: “secret query”/client guard (không an toàn) — chỉ dùng khi demo nội bộ.
|
||||
|
||||
> Mục tiêu: không cho public truy cập đường dẫn admin một cách dễ dàng trong môi trường triển khai.
|
||||
|
||||
## 10) Xoá toàn bộ phần không liên quan IPV6 (LAMS)
|
||||
|
||||
### 10.1 Nguyên tắc dọn dẹp
|
||||
|
||||
- Giữ lại:
|
||||
- cấu trúc Next.js/TS/ESLint
|
||||
- cấu trúc `app/` và các helper cần thiết
|
||||
- pipeline CSS nếu dùng
|
||||
- Xoá/đổi tên toàn bộ:
|
||||
- Metadata/title/description liên quan LAMS
|
||||
- Pages/routes/components/services/api types chỉ phục vụ LAMS
|
||||
- Static assets LAMS (logo, favicon…) nếu không dùng
|
||||
|
||||
### 10.2 Checklist dọn dẹp cụ thể
|
||||
|
||||
- `package.json`:
|
||||
- đổi `"name"` → `ipv6` (hoặc `ipv6-summit`)
|
||||
- rà soát dependency không dùng (axios nếu không cần)
|
||||
- `app/layout.tsx`:
|
||||
- cập nhật metadata sang IPV6
|
||||
- cập nhật favicon/logo/fonts đúng theo IPV6
|
||||
- `app/api/**` (nếu đang gọi CMS cho LAMS):
|
||||
- xoá toàn bộ nếu không dùng trong scope frontend-only
|
||||
- `app/components/**`:
|
||||
- thay Header/Footer LAMS bằng Header/Footer IPV6
|
||||
- Các route/pages LAMS (ví dụ `/contact`, `/student-support`, `/request`, … nếu tồn tại trong `app/`):
|
||||
- xoá để tránh rác routing
|
||||
|
||||
## 11) Quy trình thực hiện chuyển đổi (giống “lần trước”)
|
||||
|
||||
### 11.1 Chuẩn bị
|
||||
|
||||
- B1: Snapshot danh sách routes hiện tại (để biết thứ gì cần xoá).
|
||||
- B2: Xác định style strategy (Tailwind nội bộ vs SCSS).
|
||||
- B3: Tạo skeleton folders: `(site)`, `(internal)`, `components`, `data`.
|
||||
|
||||
### 11.2 Chuyển HTML → TSX theo từng trang
|
||||
|
||||
- B4: Home (`test.html`)
|
||||
- Tách Header/Footer
|
||||
- Tách sections
|
||||
- Tách data ra `home.json` + `site.json`
|
||||
- Loại bỏ `<script>` và chuyển sang state
|
||||
- B5: Registration (`registration.html`)
|
||||
- Tách form + fields
|
||||
- Tách copy/options JSON
|
||||
- B6: Feedback (`feedback.html`)
|
||||
- B7: Sponsor (`sponsor.html`)
|
||||
- B8: Admin (`admin.html`)
|
||||
- Tạo route group `(internal)`
|
||||
- Dựng table UI từ static JSON
|
||||
- Thêm middleware bảo vệ
|
||||
|
||||
### 11.3 Chuẩn hoá
|
||||
|
||||
- B9: Đồng bộ typography/colors (global CSS + config).
|
||||
- B10: Responsive pass + pixel polish (width/height, spacing).
|
||||
- B11: Cleanup LAMS triệt để.
|
||||
|
||||
## 12) Test plan (frontend-only)
|
||||
|
||||
- **Routing**:
|
||||
- `/` home load OK
|
||||
- `/registration`, `/feedback`, `/sponsor` load OK
|
||||
- `/admin` bị chặn nếu chưa auth (theo middleware)
|
||||
- **Responsive**:
|
||||
- Header: mobile menu hoạt động, không scroll nền khi mở drawer
|
||||
- Sections: không overflow-x
|
||||
- Forms: input full width mobile, label rõ ràng
|
||||
- **Accessibility tối thiểu**:
|
||||
- Label liên kết input
|
||||
- Button type/aria cho menu toggle
|
||||
- Focus ring rõ
|
||||
|
||||
## 13) Deliverables
|
||||
|
||||
- Next.js App Router pages đúng 5 trang yêu cầu.
|
||||
- Component hoá toàn bộ, Header/Footer tách riêng.
|
||||
- Static data tách JSON.
|
||||
- CSS/design system thống nhất + responsive chuẩn.
|
||||
- Xoá toàn bộ phần LAMS không liên quan, repo gọn chỉ còn IPV6.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Response interceptor for basic error handling
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
console.error('API Error:', error.response?.data || error.message);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default axiosInstance;
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'storage.googleapis.com',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "localhost",
|
||||
port: "3001",
|
||||
pathname: "/**",
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "127.0.0.1",
|
||||
port: "3001",
|
||||
pathname: "/**",
|
||||
}
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "hai-learning",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"scss:build": "sass public/assets/scss/main.scss public/assets/css/main.css --style=expanded --source-map",
|
||||
"scss:watch": "sass --watch public/assets/scss/main.scss:public/assets/css/main.css --style=expanded --source-map"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.4",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"sass": "^1.98.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,94 @@
|
||||
@font-face {
|
||||
font-family: 'icomoon';
|
||||
src: url('../icomon/icomoon.eot?jkvd7w');
|
||||
src: url('../icomon/icomoon.eot?jkvd7w#iefix') format('embedded-opentype'),
|
||||
url('../icomon/icomoon.ttf?jkvd7w') format('truetype'),
|
||||
url('../icomon/icomoon.woff?jkvd7w') format('woff'),
|
||||
url('../icomon/icomoon.svg?jkvd7w#icomoon') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
[class^="icon-"], [class*=" icon-"] {
|
||||
/* use !important to prevent issues with browser extensions that change fonts */
|
||||
font-family: 'icomoon' !important;
|
||||
speak: never;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
line-height: 1;
|
||||
|
||||
/* Better Font Rendering =========== */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-icon-1:before {
|
||||
content: "\e900";
|
||||
}
|
||||
.icon-icon-2:before {
|
||||
content: "\e901";
|
||||
}
|
||||
.icon-icon-3:before {
|
||||
content: "\e902";
|
||||
}
|
||||
.icon-icon-4:before {
|
||||
content: "\e903";
|
||||
}
|
||||
.icon-icon-5:before {
|
||||
content: "\e904";
|
||||
}
|
||||
.icon-icon-6:before {
|
||||
content: "\e905";
|
||||
}
|
||||
.icon-icon-7:before {
|
||||
content: "\e906";
|
||||
}
|
||||
.icon-icon-8:before {
|
||||
content: "\e907";
|
||||
}
|
||||
.icon-icon-9:before {
|
||||
content: "\e908";
|
||||
}
|
||||
.icon-icon-10:before {
|
||||
content: "\e909";
|
||||
}
|
||||
.icon-icon-11:before {
|
||||
content: "\e90a";
|
||||
}
|
||||
.icon-icon-12:before {
|
||||
content: "\e90b";
|
||||
}
|
||||
.icon-icon-13:before {
|
||||
content: "\e90c";
|
||||
}
|
||||
.icon-icon-14:before {
|
||||
content: "\e90d";
|
||||
}
|
||||
.icon-icon-15:before {
|
||||
content: "\e90e";
|
||||
}
|
||||
.icon-icon-16:before {
|
||||
content: "\e90f";
|
||||
}
|
||||
.icon-icon-17:before {
|
||||
content: "\e910";
|
||||
}
|
||||
.icon-icon-18:before {
|
||||
content: "\e911";
|
||||
}
|
||||
.icon-icon-19:before {
|
||||
content: "\e912";
|
||||
}
|
||||
.icon-icon-20:before {
|
||||
content: "\e913";
|
||||
}
|
||||
.icon-icon-21:before {
|
||||
content: "\e914";
|
||||
}
|
||||
.icon-icon-22:before {
|
||||
content: "\e915";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
/* Magnific Popup CSS */
|
||||
.mfp-bg {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1042;
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
background: #0b0b0b;
|
||||
opacity: 0.8; }
|
||||
|
||||
.mfp-wrap {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1043;
|
||||
position: fixed;
|
||||
outline: none !important;
|
||||
-webkit-backface-visibility: hidden; }
|
||||
|
||||
.mfp-container {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
top: 0;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box; }
|
||||
|
||||
.mfp-container:before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
vertical-align: middle; }
|
||||
|
||||
.mfp-align-top .mfp-container:before {
|
||||
display: none; }
|
||||
|
||||
.mfp-content {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin: 0 auto;
|
||||
text-align: left;
|
||||
z-index: 1045; }
|
||||
|
||||
.mfp-inline-holder .mfp-content,
|
||||
.mfp-ajax-holder .mfp-content {
|
||||
width: 100%;
|
||||
cursor: auto; }
|
||||
|
||||
.mfp-ajax-cur {
|
||||
cursor: progress; }
|
||||
|
||||
.mfp-zoom-out-cur, .mfp-zoom-out-cur .mfp-image-holder .mfp-close {
|
||||
cursor: -moz-zoom-out;
|
||||
cursor: -webkit-zoom-out;
|
||||
cursor: zoom-out; }
|
||||
|
||||
.mfp-zoom {
|
||||
cursor: pointer;
|
||||
cursor: -webkit-zoom-in;
|
||||
cursor: -moz-zoom-in;
|
||||
cursor: zoom-in; }
|
||||
|
||||
.mfp-auto-cursor .mfp-content {
|
||||
cursor: auto; }
|
||||
|
||||
.mfp-close,
|
||||
.mfp-arrow,
|
||||
.mfp-preloader,
|
||||
.mfp-counter {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none; }
|
||||
|
||||
.mfp-loading.mfp-figure {
|
||||
display: none; }
|
||||
|
||||
.mfp-hide {
|
||||
display: none !important; }
|
||||
|
||||
.mfp-preloader {
|
||||
color: #CCC;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: auto;
|
||||
text-align: center;
|
||||
margin-top: -0.8em;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
z-index: 1044; }
|
||||
.mfp-preloader a {
|
||||
color: #CCC; }
|
||||
.mfp-preloader a:hover {
|
||||
color: $white; }
|
||||
|
||||
.mfp-s-ready .mfp-preloader {
|
||||
display: none; }
|
||||
|
||||
.mfp-s-error .mfp-content {
|
||||
display: none; }
|
||||
|
||||
button.mfp-close,
|
||||
button.mfp-arrow {
|
||||
overflow: visible;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
-webkit-appearance: none;
|
||||
display: block;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
z-index: 1046;
|
||||
box-shadow: none;
|
||||
touch-action: manipulation; }
|
||||
|
||||
button::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border: 0; }
|
||||
|
||||
.mfp-close {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
opacity: 0.65;
|
||||
padding: 0 0 18px 10px;
|
||||
color: $white;
|
||||
font-style: normal;
|
||||
font-size: 28px;
|
||||
font-family: Arial, Baskerville, monospace; }
|
||||
.mfp-close:hover,
|
||||
.mfp-close:focus {
|
||||
opacity: 1; }
|
||||
.mfp-close:active {
|
||||
top: 1px; }
|
||||
|
||||
.mfp-close-btn-in .mfp-close {
|
||||
color: #fff; }
|
||||
|
||||
.mfp-image-holder .mfp-close,
|
||||
.mfp-iframe-holder .mfp-close {
|
||||
color: $white;
|
||||
right: -6px;
|
||||
text-align: right;
|
||||
padding-right: 6px;
|
||||
width: 100%; }
|
||||
|
||||
.mfp-counter {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
color: #CCC;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
white-space: nowrap; }
|
||||
|
||||
.mfp-arrow {
|
||||
position: absolute;
|
||||
opacity: 0.65;
|
||||
margin: 0;
|
||||
top: 50%;
|
||||
margin-top: -55px;
|
||||
padding: 0;
|
||||
width: 90px;
|
||||
height: 110px;
|
||||
-webkit-tap-highlight-color: transparent; }
|
||||
.mfp-arrow:active {
|
||||
margin-top: -54px; }
|
||||
.mfp-arrow:hover,
|
||||
.mfp-arrow:focus {
|
||||
opacity: 1; }
|
||||
.mfp-arrow:before,
|
||||
.mfp-arrow:after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
margin-top: 35px;
|
||||
margin-left: 35px;
|
||||
border: medium inset transparent; }
|
||||
.mfp-arrow:after {
|
||||
border-top-width: 13px;
|
||||
border-bottom-width: 13px;
|
||||
top: 8px; }
|
||||
.mfp-arrow:before {
|
||||
border-top-width: 21px;
|
||||
border-bottom-width: 21px;
|
||||
opacity: 0.7; }
|
||||
|
||||
.mfp-arrow-left {
|
||||
left: 0; }
|
||||
.mfp-arrow-left:after {
|
||||
border-right: 17px solid $white;
|
||||
margin-left: 31px; }
|
||||
.mfp-arrow-left:before {
|
||||
margin-left: 25px;
|
||||
border-right: 27px solid #fff; }
|
||||
|
||||
.mfp-arrow-right {
|
||||
right: 0; }
|
||||
.mfp-arrow-right:after {
|
||||
border-left: 17px solid $white;
|
||||
margin-left: 39px; }
|
||||
.mfp-arrow-right:before {
|
||||
border-left: 27px solid #fff; }
|
||||
|
||||
.mfp-iframe-holder {
|
||||
padding-top: 40px;
|
||||
padding-bottom: 40px; }
|
||||
.mfp-iframe-holder .mfp-content {
|
||||
line-height: 0;
|
||||
width: 100%;
|
||||
max-width: 900px; }
|
||||
.mfp-iframe-holder .mfp-close {
|
||||
top: -40px; }
|
||||
|
||||
.mfp-iframe-scaler {
|
||||
width: 100%;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
padding-top: 56.25%; }
|
||||
.mfp-iframe-scaler iframe {
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.6);
|
||||
background: #000; }
|
||||
|
||||
/* Main image in popup */
|
||||
img.mfp-img {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
line-height: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 40px 0 40px;
|
||||
margin: 0 auto; }
|
||||
|
||||
/* The shadow behind the image */
|
||||
.mfp-figure {
|
||||
line-height: 0; }
|
||||
.mfp-figure:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 40px;
|
||||
bottom: 40px;
|
||||
display: block;
|
||||
right: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
z-index: -1;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.6);
|
||||
background: #444; }
|
||||
.mfp-figure small {
|
||||
color: #BDBDBD;
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
line-height: 14px; }
|
||||
.mfp-figure figure {
|
||||
margin: 0; }
|
||||
|
||||
.mfp-bottom-bar {
|
||||
margin-top: -36px;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
cursor: auto; }
|
||||
|
||||
.mfp-title {
|
||||
text-align: left;
|
||||
line-height: 18px;
|
||||
color: #F3F3F3;
|
||||
word-wrap: break-word;
|
||||
padding-right: 36px; }
|
||||
|
||||
.mfp-image-holder .mfp-content {
|
||||
max-width: 100%; }
|
||||
|
||||
.mfp-gallery .mfp-image-holder .mfp-figure {
|
||||
cursor: pointer; }
|
||||
|
||||
@media screen and (max-width: 800px) and (orientation: landscape), screen and (max-height: 300px) {
|
||||
/**
|
||||
* Remove all paddings around the image on small screen
|
||||
*/
|
||||
.mfp-img-mobile .mfp-image-holder {
|
||||
padding-left: 0;
|
||||
padding-right: 0; }
|
||||
.mfp-img-mobile img.mfp-img {
|
||||
padding: 0; }
|
||||
.mfp-img-mobile .mfp-figure:after {
|
||||
top: 0;
|
||||
bottom: 0; }
|
||||
.mfp-img-mobile .mfp-figure small {
|
||||
display: inline;
|
||||
margin-left: 5px; }
|
||||
.mfp-img-mobile .mfp-bottom-bar {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
bottom: 0;
|
||||
margin: 0;
|
||||
top: auto;
|
||||
padding: 3px 5px;
|
||||
position: fixed;
|
||||
box-sizing: border-box; }
|
||||
.mfp-img-mobile .mfp-bottom-bar:empty {
|
||||
padding: 0; }
|
||||
.mfp-img-mobile .mfp-counter {
|
||||
right: 5px;
|
||||
top: 3px; }
|
||||
.mfp-img-mobile .mfp-close {
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
line-height: 35px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
position: fixed;
|
||||
text-align: center;
|
||||
padding: 0; } }
|
||||
|
||||
@media all and (max-width: 900px) {
|
||||
.mfp-arrow {
|
||||
-webkit-transform: scale(0.75);
|
||||
transform: scale(0.75); }
|
||||
.mfp-arrow-left {
|
||||
-webkit-transform-origin: 0;
|
||||
transform-origin: 0; }
|
||||
.mfp-arrow-right {
|
||||
-webkit-transform-origin: 100%;
|
||||
transform-origin: 100%; }
|
||||
.mfp-container {
|
||||
padding-left: 6px;
|
||||
padding-right: 6px; } }
|
||||
@@ -0,0 +1,148 @@
|
||||
/*! #######################################################################
|
||||
|
||||
MeanMenu 2.0.7
|
||||
--------
|
||||
|
||||
To be used with jquery.meanmenu.js by Chris Wharton (http://www.meanthemes.com/plugins/meanmenu/)
|
||||
|
||||
####################################################################### */
|
||||
|
||||
a.meanmenu-reveal {
|
||||
display: none
|
||||
}
|
||||
|
||||
.mean-container .mean-bar {
|
||||
float: left;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
background: #070337;
|
||||
padding: 4px 0;
|
||||
min-height: 42px;
|
||||
z-index: 999999
|
||||
}
|
||||
|
||||
.mean-container a.meanmenu-reveal {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 13px 13px 11px 13px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
text-indent: -9999em;
|
||||
line-height: 22px;
|
||||
font-size: 1px;
|
||||
display: block;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-weight: 700
|
||||
}
|
||||
|
||||
.mean-container a.meanmenu-reveal span {
|
||||
display: block;
|
||||
background: #fff;
|
||||
height: 3px;
|
||||
margin-top: 3px
|
||||
}
|
||||
|
||||
.mean-container .mean-nav {
|
||||
float: left;
|
||||
width: 100%;
|
||||
background: #070337;
|
||||
margin-top: 44px
|
||||
}
|
||||
|
||||
.mean-container .mean-nav ul {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
list-style-type: none
|
||||
}
|
||||
|
||||
.mean-container .mean-nav ul li {
|
||||
position: relative;
|
||||
float: left;
|
||||
width: 100%
|
||||
}
|
||||
|
||||
.mean-container .mean-nav ul li a {
|
||||
display: block;
|
||||
float: left;
|
||||
width: 90%;
|
||||
padding: 10px 5%;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
color: #fff;
|
||||
border-top: 1px solid #e0e3ed;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase
|
||||
}
|
||||
|
||||
.mean-container .mean-nav ul li li a {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
text-shadow: none !important;
|
||||
visibility: visible
|
||||
}
|
||||
|
||||
.mean-container .mean-nav ul li.mean-last a {
|
||||
border-bottom: 0;
|
||||
margin-bottom: 0
|
||||
}
|
||||
|
||||
.mean-container .mean-nav ul li li li a {
|
||||
width: 70%;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.mean-container .mean-nav ul li li li li a {
|
||||
width: 60%;
|
||||
padding: 10px 20%
|
||||
}
|
||||
|
||||
.mean-container .mean-nav ul li li li li li a {
|
||||
width: 50%;
|
||||
padding: 10px 25%
|
||||
}
|
||||
|
||||
.mean-container .mean-nav ul li a.mean-expand {
|
||||
margin-top: 1px;
|
||||
width: 26px;
|
||||
height: 32px;
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
font-weight: 700;
|
||||
background: transparent;
|
||||
border: none !important;
|
||||
font-size: 14px
|
||||
}
|
||||
|
||||
.mean-container .mean-push {
|
||||
float: left;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
clear: both
|
||||
}
|
||||
|
||||
.mean-nav .wrapper {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0
|
||||
}
|
||||
|
||||
.mean-container .mean-bar,
|
||||
.mean-container .mean-bar * {
|
||||
-webkit-box-sizing: content-box;
|
||||
-moz-box-sizing: content-box;
|
||||
box-sizing: content-box
|
||||
}
|
||||
|
||||
.mean-remove {
|
||||
display: none !important
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
.nice-select {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
background-color: transparent;
|
||||
border-radius: 5px;
|
||||
border: solid 1px #3f3877;
|
||||
box-sizing: border-box;
|
||||
clear: both;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
font-family: inherit;
|
||||
font-size: 18px;
|
||||
font-weight: normal;
|
||||
outline: none;
|
||||
padding: 14px 35px 14px 15px;
|
||||
position: relative;
|
||||
text-align: left !important;
|
||||
-webkit-transition: all 0.2s ease-in-out;
|
||||
transition: all 0.2s ease-in-out;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
width: 160px;
|
||||
}
|
||||
.nice-select:hover {
|
||||
border-color: #3f3877; }
|
||||
.nice-select:active, .nice-select.open, .nice-select:focus {
|
||||
border-color: #3f3877; }
|
||||
.nice-select:after {
|
||||
border-bottom: 2px solid #7b7c98;
|
||||
border-right: 2px solid #7b7c98;
|
||||
content: '';
|
||||
display: block;
|
||||
height: 12px;
|
||||
margin-top: -6px;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
top: 50%;
|
||||
-webkit-transform-origin: 66% 66%;
|
||||
-ms-transform-origin: 66% 66%;
|
||||
transform-origin: 66% 66%;
|
||||
-webkit-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
-webkit-transition: all 0.15s ease-in-out;
|
||||
transition: all 0.15s ease-in-out;
|
||||
width: 12px; }
|
||||
.nice-select.open:after {
|
||||
-webkit-transform: rotate(-135deg);
|
||||
-ms-transform: rotate(-135deg);
|
||||
transform: rotate(-135deg); }
|
||||
.nice-select.open .list {
|
||||
opacity: 1;
|
||||
display: block;
|
||||
pointer-events: auto;
|
||||
-webkit-transform: scale(1) translateY(0);
|
||||
-ms-transform: scale(1) translateY(0);
|
||||
transform: scale(1) translateY(0); }
|
||||
.nice-select.disabled {
|
||||
border-color: #3f3877;
|
||||
color: #3f3877;
|
||||
pointer-events: none; }
|
||||
.nice-select.disabled:after {
|
||||
border-color: #3f3877; }
|
||||
.nice-select.wide {
|
||||
width: 100%; }
|
||||
.nice-select.wide .list {
|
||||
left: 0 !important;
|
||||
right: 0 !important; }
|
||||
.nice-select.right {
|
||||
float: right; }
|
||||
.nice-select.right .list {
|
||||
left: auto;
|
||||
right: 0; }
|
||||
.nice-select.small {
|
||||
font-size: 12px;
|
||||
height: 36px;
|
||||
line-height: 34px; }
|
||||
.nice-select.small:after {
|
||||
height: 4px;
|
||||
width: 4px; }
|
||||
.nice-select.small .option {
|
||||
line-height: 34px;
|
||||
min-height: 34px; }
|
||||
.nice-select .list {
|
||||
background-color: #150145;
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
margin-top: 4px;
|
||||
opacity: 0;
|
||||
width: 160px;
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
-webkit-transform-origin: 50% 0;
|
||||
-ms-transform-origin: 50% 0;
|
||||
transform-origin: 50% 0;
|
||||
-webkit-transform: scale(0.75) translateY(-21px);
|
||||
-ms-transform: scale(0.75) translateY(-21px);
|
||||
transform: scale(0.75) translateY(-21px);
|
||||
-webkit-transition: all 0.2s cubic-bezier(0.5, 0, 0, 1.25), opacity 0.15s ease-out;
|
||||
transition: all 0.2s cubic-bezier(0.5, 0, 0, 1.25), opacity 0.15s ease-out;
|
||||
z-index: 9;
|
||||
margin-bottom: 4px;
|
||||
|
||||
}
|
||||
.nice-select .list li:not(:last-child){
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
.nice-select .option {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
line-height: 40px;
|
||||
list-style: none;
|
||||
min-height: 40px;
|
||||
outline: none;
|
||||
z-index: 9999999;
|
||||
padding: 0px 10px;
|
||||
border: 1px solid #121254;
|
||||
text-align: left;
|
||||
-webkit-transition: all 0.2s;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.nice-select .option:hover, .nice-select .option.focus, .nice-select .option.selected.focus {
|
||||
background-color: #101046; }
|
||||
.nice-select .option.selected {
|
||||
font-weight: bold; }
|
||||
.nice-select .option.disabled {
|
||||
background-color: transparent;
|
||||
color: #999;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.no-csspointerevents .nice-select .list {
|
||||
display: none; }
|
||||
|
||||
.no-csspointerevents .nice-select.open .list {
|
||||
display: block; }
|
||||
@@ -0,0 +1,100 @@
|
||||
.odometer.odometer-auto-theme,
|
||||
.odometer.odometer-theme-default {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
}
|
||||
.odometer.odometer-auto-theme .odometer-digit,
|
||||
.odometer.odometer-theme-default .odometer-digit {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
position: relative;
|
||||
}
|
||||
.odometer.odometer-auto-theme .odometer-digit .odometer-digit-spacer,
|
||||
.odometer.odometer-theme-default .odometer-digit .odometer-digit-spacer {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
visibility: hidden;
|
||||
}
|
||||
.odometer.odometer-auto-theme .odometer-digit .odometer-digit-inner,
|
||||
.odometer.odometer-theme-default .odometer-digit .odometer-digit-inner {
|
||||
text-align: left;
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.odometer.odometer-auto-theme .odometer-digit .odometer-ribbon,
|
||||
.odometer.odometer-theme-default .odometer-digit .odometer-ribbon {
|
||||
display: block;
|
||||
}
|
||||
.odometer.odometer-auto-theme .odometer-digit .odometer-ribbon-inner,
|
||||
.odometer.odometer-theme-default .odometer-digit .odometer-ribbon-inner {
|
||||
display: block;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
.odometer.odometer-auto-theme .odometer-digit .odometer-value,
|
||||
.odometer.odometer-theme-default .odometer-digit .odometer-value {
|
||||
display: block;
|
||||
-webkit-transform: translateZ(0);
|
||||
transform: translateZ(0);
|
||||
}
|
||||
.odometer.odometer-auto-theme
|
||||
.odometer-digit
|
||||
.odometer-value.odometer-last-value,
|
||||
.odometer.odometer-theme-default
|
||||
.odometer-digit
|
||||
.odometer-value.odometer-last-value {
|
||||
position: absolute;
|
||||
}
|
||||
.odometer.odometer-auto-theme.odometer-animating-up .odometer-ribbon-inner,
|
||||
.odometer.odometer-theme-default.odometer-animating-up .odometer-ribbon-inner {
|
||||
-webkit-transition: -webkit-transform 2s;
|
||||
-moz-transition: -moz-transform 2s;
|
||||
-ms-transition: -ms-transform 2s;
|
||||
-o-transition: -o-transform 2s;
|
||||
transition: transform 2s;
|
||||
}
|
||||
.odometer.odometer-auto-theme.odometer-animating-up.odometer-animating
|
||||
.odometer-ribbon-inner,
|
||||
.odometer.odometer-theme-default.odometer-animating-up.odometer-animating
|
||||
.odometer-ribbon-inner {
|
||||
-webkit-transform: translateY(-100%);
|
||||
-moz-transform: translateY(-100%);
|
||||
-ms-transform: translateY(-100%);
|
||||
-o-transform: translateY(-100%);
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
.odometer.odometer-auto-theme.odometer-animating-down .odometer-ribbon-inner,
|
||||
.odometer.odometer-theme-default.odometer-animating-down
|
||||
.odometer-ribbon-inner {
|
||||
-webkit-transform: translateY(-100%);
|
||||
-moz-transform: translateY(-100%);
|
||||
-ms-transform: translateY(-100%);
|
||||
-o-transform: translateY(-100%);
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
.odometer.odometer-auto-theme.odometer-animating-down.odometer-animating
|
||||
.odometer-ribbon-inner,
|
||||
.odometer.odometer-theme-default.odometer-animating-down.odometer-animating
|
||||
.odometer-ribbon-inner {
|
||||
-webkit-transition: -webkit-transform 2s;
|
||||
-moz-transition: -moz-transform 2s;
|
||||
-ms-transition: -ms-transform 2s;
|
||||
-o-transition: -o-transform 2s;
|
||||
transition: transform 2s;
|
||||
-webkit-transform: translateY(0);
|
||||
-moz-transform: translateY(0);
|
||||
-ms-transform: translateY(0);
|
||||
-o-transform: translateY(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.odometer.odometer-auto-theme .odometer-value,
|
||||
.odometer.odometer-theme-default .odometer-value {
|
||||
text-align: center;
|
||||
}
|
||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 604 KiB |
|
After Width: | Height: | Size: 232 B |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 826 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 391 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 1.9 KiB |