50 KiB
Portfolio Tết Theme - Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development OR superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox syntax for tracking.
Goal: Build a fully polished, production-ready portfolio with Next.js 14, Tailwind, Framer Motion, and Tết theme.
Architecture: Next.js App Router with TypeScript, components in components/, data in data/, effects in components/TetEffects.tsx. Single-page Home with scroll sections; separate pages for Projects and My Corner blog.
Tech Stack: Next.js 14+, TypeScript, Tailwind CSS, Framer Motion, Google Fonts.
Task 1: Initialize Next.js project
Files:
-
Create:
package.json -
Create:
next.config.js -
Create:
tsconfig.json -
Create:
tailwind.config.ts -
Create:
postcss.config.mjs -
Create:
app/layout.tsx -
Create:
app/page.tsx -
Create:
app/globals.css -
Step 1: Run create-next-app with TypeScript and Tailwind
cd /home/node/.openclaw/workspace/projects/clawteam-portfolio
npx create-next-app@latest . --typescript --tailwind --eslint --app --src-dir --no-turbopack --import-alias "@/*"
Accept defaults. This creates the base structure.
- Step 2: Install additional dependencies
npm install framer-motion
npm install -D @types/node
- Step 3: Configure Tailwind colors for Tết theme
Edit tailwind.config.ts:
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#FFEBEE',
100: '#FFCDD2',
200: '#EF9A9A',
300: '#E57373',
400: '#EF5350',
500: '#F44336',
600: '#D32F2F',
700: '#C62828',
800: '#B71C1C',
900: '#FFCDD2',
},
accent: {
50: '#FFF8E1',
100: '#FFECB3',
200: '#FFE082',
300: '#FFD54F',
400: '#FFCA28',
500: '#FFC107',
600: '#FFB300',
700: '#FFA000',
800: '#FF8F00',
900: '#FF6F00',
},
},
fontFamily: {
heading: ['"Playfair Display"', 'serif'],
body: ['"Inter"', 'sans-serif'],
},
},
},
plugins: [],
};
export default config;
- Step 4: Load Google Fonts in
app/layout.tsx
Edit app/layout.tsx:
import type { Metadata } from "next";
import { Inter, Playfair_Display } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
const playfair = Playfair_Display({
subsets: ["latin"],
variable: "--font-playfair",
});
export const metadata: Metadata = {
title: "Trí Vĩ | AI Engineer",
description: "Personal portfolio of Nguyễn Ngọc Trí Vĩ - AI Engineer",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${inter.variable} ${playfair.variable} font-body`}>
{children}
</body>
</html>
);
}
- Step 5: Set base styles in
app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--font-inter: var(--font-inter);
--font-playfair: var(--font-playfair);
}
body {
@apply bg-white text-gray-900;
}
h1, h2, h3, h4, h5, h6 {
@apply font-heading;
}
- Step 6: Commit initial setup
git add .
git commit -m "feat: init Next.js project with Tailwind and fonts"
Task 2: Create data layer
Files:
-
Create:
src/data/profile.ts -
Create:
src/data/experience.ts -
Create:
src/data/projects.ts -
Create:
src/data/blogPosts.ts -
Step 1: Copy avatar image to
public/avatar.jpeg(already exists in projects dir)
cp /home/node/.openclaw/workspace/projects/clawteam-portfolio/avatar.jpeg public/avatar.jpeg
- Step 2: Create
src/data/profile.ts
export const profile = {
name: "Nguyễn Ngọc Trí Vĩ",
title: "AI Engineer",
avatar: "/avatar.jpeg",
email: "nntrivi2001@gmail.com",
phone: "090 321 5095",
linkedin: "https://linkedin.com/in/nguyen-ngoc-tri-vi",
portfolio: "https://nguyenngoctrivi.vercel.app",
languages: ["Vietnamese (native)", "English (intermediate)"],
bio: `I'm an AI Engineer passionate about applying cutting-edge machine learning to real-world problems. With a strong foundation in Computer Vision and AI research, I've built solutions for industrial quality inspection, trading automation, and educational AI tools. I love exploring new technologies and turning complex ideas into practical, impactful products.`,
};
- Step 3: Create
src/data/experience.ts
export interface Experience {
id: number;
company: string;
role: string;
startDate: string;
endDate: string;
description: string[];
}
export const experience: Experience[] = [
{
id: 1,
company: "SmartGift Solution",
role: "AI Engineer",
startDate: "Mar 2026",
endDate: "Present",
description: [
"Research and deploy AI assistants to improve development team productivity.",
"Explore and apply suitable technologies for business problems, build POCs, and document practical recommendations.",
],
},
{
id: 2,
company: "Learning Chain Vietnam",
role: "Applied AI Researcher & Operations Associate",
startDate: "Jul 2025",
endDate: "Mar 2026",
description: [
"Research and implement AI for trading automation and operations support.",
"Explore and execute suitable tech, build POCs and document actionable recommendations.",
"Design and teach applied AI training programs, workshops, and n8n templates.",
"Support COO in optimizing workflows, reporting, and cross-team process standardization.",
],
},
{
id: 3,
company: "Vườn đậu - Thương hiệu Sữa đậu nành Thảo mộc",
role: "Founder",
startDate: "Oct 2024",
endDate: "Apr 2025",
description: [
"Managed entire production and business process: sourcing, manufacturing, packaging, distribution, and inventory control.",
"Planned raw material procurement, production schedules, and warehousing to optimize costs and maintain product quality.",
"Developed pricing strategy and direct sales channels. Achieved strong customer satisfaction with average repurchase rate of 2.51x per customer.",
],
},
{
id: 4,
company: "VSTECH Company Limited",
role: "Computer Vision Engineer",
startDate: "Aug 2023",
endDate: "Feb 2025",
description: [
"Nozzle Quality Inspection: Built for Japanese client on production line.",
"3D Vision Integration: Measured Samsung LED panel height with ±7% accuracy.",
"OCR Solutions: Read package codes and text labels with high accuracy in industrial environment.",
],
},
];
- Step 4: Create
src/data/projects.ts
export interface Project {
id: number;
title: string;
description: string;
tech: string[];
demoUrl?: string;
githubUrl?: string;
image?: string;
}
export const projects: Project[] = [
{
id: 1,
title: "Nozzle Quality Inspection System",
description:
"Computer vision system for automatic nozzle defect detection on production lines. Deployed at Japanese manufacturing facility.",
tech: ["Python", "OpenCV", "TensorFlow", "Industrial Camera"],
demoUrl: "https://nguyenngoctrivi.vercel.app",
githubUrl: "https://github.com/yourusername/nozzle-inspection",
},
{
id: 2,
title: "3D Panel Measurement",
description:
"3D vision integration to measure LED panel height with ±7% accuracy for Samsung.",
tech: ["3D Vision", "Python", "C++", "Point Cloud Processing"],
},
{
id: 3,
title: "OCR for Package Codes",
description:
"High-accuracy OCR for reading package codes and text labels in industrial environments.",
tech: ["OCR", "Python", "OpenCV", "Deep Learning"],
},
{
id: 4,
title: "AI Trading Assistant",
description:
"Research and implementation of AI-driven trading automation and operations support.",
tech: ["Python", "n8n", "PineScript", "Trading APIs"],
},
{
id: 5,
title: "Applied AI Training Program",
description:
"Designed and delivered applied AI training courses, workshops, and learning assets (n8n templates).",
tech: ["AI Education", "n8n", "Workshop Design"],
},
];
- Step 5: Create
src/data/blogPosts.ts
export interface BlogPost {
slug: string;
title: string;
date: string;
excerpt: string;
content: string;
image?: string;
tags: string[];
}
export const blogPosts: BlogPost[] = [
{
slug: "journey-into-ai",
title: "My Journey into AI",
date: "2025-12-15",
excerpt:
"Reflecting on how I got started with AI, from university days to real-world deployments.",
content: `
# My Journey into AI
It all started at university...
## The Beginning
I was fascinated by how machines could learn...
## First Project
My first computer vision project taught me...
## Lessons Learned
- Start with the fundamentals
- Build real things
- Never stop learning
This journey has been incredible, and I'm excited for what's next.
`.trim(),
tags: ["personal", "ai", "career"],
},
{
slug: "tet-vibes-2025",
title: "Tết 2025 - A Time of Renewal",
date: "2025-01-28",
excerpt:
"Celebrating Lunar New Year with family, food, and wishes for the year ahead.",
content: `
# Tết 2025 - A Time of Renewal
Tết is always a special time...
## Family Reunion
Returning home, sharing meals...
## Traditions
Visiting relatives, lucky money...
## Looking Forward
New year, new goals, new hope.
Chúc mừng năm mới! 🎊
`.trim(),
image: "/images/tet-2025.jpg",
tags: ["personal", "tet", "life"],
},
];
- Step 6: Commit data layer
git add src/data/ public/avatar.jpeg
git commit -m "feat: add data layer (profile, experience, projects, blog)"
Task 3: Build TetEffects component (falling petals + subtle fireworks)
Files:
-
Create:
src/components/TetEffects.tsx -
Step 1: Create falling petals canvas component
"use client";
import { useEffect, useRef } from "react";
interface Petal {
x: number;
y: number;
size: number;
speedY: number;
speedX: number;
rotation: number;
rotationSpeed: number;
opacity: number;
}
export default function TetEffects() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
resize();
window.addEventListener("resize", resize);
const petals: Petal[] = [];
const colors = ["#F44336", "#E91E63", "#D32F2F", "#FFC107", "#FFEB3B"]; // reds and golds
const createPetal = () => ({
x: Math.random() * canvas.width,
y: -10,
size: 4 + Math.random() * 8,
speedY: 0.5 + Math.random() * 1.5,
speedX: (Math.random() - 0.5) * 0.5,
rotation: Math.random() * Math.PI * 2,
rotationSpeed: (Math.random() - 0.5) * 0.02,
opacity: 0.4 + Math.random() * 0.4,
});
// Initial petals
for (let i = 0; i < 50; i++) {
const p = createPetal();
p.y = Math.random() * canvas.height;
petals.push(p);
}
let animationFrameId: number;
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
petals.forEach((petal, idx) => {
// Update
petal.y += petal.speedY;
petal.x += petal.speedX;
petal.rotation += petal.rotationSpeed;
// Reset if out of bounds
if (petal.y > canvas.height + 10) {
petals[idx] = createPetal();
}
// Draw petal (simple circle or ellipse)
ctx.save();
ctx.translate(petal.x, petal.y);
ctx.rotate(petal.rotation);
ctx.globalAlpha = petal.opacity;
ctx.fillStyle = colors[Math.floor(Math.random() * colors.length)];
ctx.beginPath();
ctx.ellipse(0, 0, petal.size, petal.size / 2, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
});
animationFrameId = requestAnimationFrame(animate);
};
animate();
return () => {
window.removeEventListener("resize", resize);
cancelAnimationFrame(animationFrameId);
};
}, []);
return (
<canvas
ref={canvasRef}
className="fixed inset-0 pointer-events-none z-50"
style={{ opacity: 0.6 }}
/>
);
}
- Step 2: Add subtle fireworks (sparkles) on idle/scroll
Enhance the same canvas in TetEffects:
// Inside useEffect after petal animation:
const sparkles: { x: number; y: number; life: number; maxLife: number; size: number }[] = [];
const createSparkle = (x?: number, y?: number) => ({
x: x ?? Math.random() * canvas.width,
y: y ?? Math.random() * canvas.height,
life: 1,
maxLife: 60 + Math.random() * 60,
size: 1 + Math.random() * 2,
});
// Occasionally create sparkles
setInterval(() => {
if (sparkles.length < 20) {
sparkles.push(createSparkle());
}
}, 300);
// In animate loop, after petals:
sparkles.forEach((sparkle, idx) => {
sparkle.life--;
if (sparkle.life <= 0) {
sparkles.splice(idx, 1);
return;
}
ctx.save();
ctx.globalAlpha = sparkle.life / sparkle.maxLife;
ctx.fillStyle = "#FFC107";
ctx.beginPath();
ctx.arc(sparkle.x, sparkle.y, sparkle.size, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
});
(Integrate into the existing animation loop.)
- Step 3: Commit TetEffects
git add src/components/TetEffects.tsx
git commit -m "feat: add TetEffects component (falling petals + fireworks)"
Task 4: Build common UI components
Files:
-
Create:
src/components/Header.tsx -
Create:
src/components/Footer.tsx -
Create:
src/components/ui/Button.tsx(optional) -
Step 1: Create
src/components/Header.tsx
"use client";
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Link from "next/link";
export default function Header() {
const [scrolled, setScrolled] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 50);
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, []);
const navLinks = [
{ href: "#home", label: "Home" },
{ href: "#about", label: "About" },
{ href: "#experience", label: "Experience" },
{ href: "#contact", label: "Contact" },
{ href: "/projects", label: "Projects" },
{ href: "/my-corner", label: "My Corner" },
];
return (
<header
className={`fixed top-0 left-0 right-0 z-40 transition-all duration-300 ${
scrolled
? "bg-white/80 backdrop-blur-md shadow-sm"
: "bg-transparent"
}`}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<Link href="/" className="text-2xl font-bold text-primary-600">
Trí Vĩ
</Link>
{/* Desktop Nav */}
<nav className="hidden md:flex space-x-8">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="text-gray-700 hover:text-primary-600 transition-colors font-medium"
>
{link.label}
</Link>
))}
</nav>
{/* Mobile menu button */}
<button
className="md:hidden p-2"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Toggle menu"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{mobileMenuOpen ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
)}
</svg>
</button>
</div>
</div>
{/* Mobile Nav */}
<AnimatePresence>
{mobileMenuOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="md:hidden bg-white shadow-lg"
>
<div className="px-4 py-4 space-y-2">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="block py-2 text-gray-700 hover:text-primary-600"
onClick={() => setMobileMenuOpen(false)}
>
{link.label}
</Link>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</header>
);
}
- Step 2: Create
src/components/Footer.tsx
import Link from "next/link";
export default function Footer() {
return (
<footer className="bg-gradient-to-r from-primary-600 to-primary-800 text-white py-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col md:flex-row justify-between items-center">
<div className="mb-4 md:mb-0">
<h3 className="text-2xl font-bold mb-2">Trí Vĩ</h3>
<p className="text-gray-200">AI Engineer</p>
</div>
<div className="text-sm">
© 2025 - 2026 Nguyễn Ngọc Trí Vĩ. All rights reserved.
</div>
</div>
{/* Decorative lion/dragon silhouette could be added here */}
</div>
</footer>
);
}
- Step 3: Commit Header & Footer
git add src/components/Header.tsx src/components/Footer.tsx
git commit -m "feat: add Header and Footer components"
Task 5: Build Hero section
Files:
-
Create:
src/components/Hero.tsx -
Step 1: Create Hero with avatar, name, title, CTA buttons
"use client";
import { motion } from "framer-motion";
import Image from "next/image";
import { profile } from "@/data/profile";
export default function Hero() {
return (
<section
id="home"
className="relative min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-500 via-accent-400 to-primary-600 overflow-hidden"
>
{/* Falling petals overlay will be rendered at app level */}
<div className="relative z-10 text-center px-4 max-w-4xl mx-auto">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.8 }}
className="mb-8"
>
<div className="relative w-48 h-48 mx-auto rounded-full overflow-hidden border-4 border-white shadow-2xl">
<Image
src={profile.avatar}
alt={profile.name}
fill
className="object-cover"
priority
/>
</div>
</motion.div>
<motion.h1
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
className="text-5xl md:text-6xl font-heading font-bold text-white mb-4"
>
{profile.name}
</motion.h1>
<motion.p
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.4 }}
className="text-2xl md:text-3xl text-gray-100 mb-8"
>
{profile.title}
</motion.p>
<motion.div
initial={{ y: 30, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.6 }}
className="flex flex-col sm:flex-row gap-4 justify-center"
>
<a
href="/projects"
className="px-8 py-3 bg-white text-primary-600 rounded-full font-semibold shadow-lg hover:shadow-xl hover:bg-gray-50 transition-all"
>
View Projects
</a>
<a
href="#contact"
className="px-8 py-3 border-2 border-white text-white rounded-full font-semibold hover:bg-white hover:text-primary-600 transition-all"
>
Contact Me
</a>
</motion.div>
</div>
</section>
);
}
-
Step 2: Add Avatar image to
public/(already copied) -
Step 3: Commit Hero
git add src/components/Hero.tsx
git commit -m "feat: add Hero section component"
Task 6: Build About section
Files:
-
Create:
src/components/About.tsx -
Step 1: Create About component (2-column layout)
"use client";
import { motion } from "framer-motion";
import Image from "next/image";
import { profile } from "@/data/profile";
export default function About() {
return (
<section id="about" className="py-20 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="text-center mb-12"
>
<h2 className="text-4xl font-heading font-bold text-primary-700 mb-4">
About Me
</h2>
<div className="w-24 h-1 bg-accent-500 mx-auto" />
</motion.div>
<div className="grid md:grid-cols-2 gap-12 items-center">
<motion.div
initial={{ x: -50, opacity: 0 }}
whileInView={{ x: 0, opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="flex justify-center"
>
<div className="relative w-80 h-80 rounded-2xl overflow-hidden shadow-2xl">
<Image
src={profile.avatar}
alt={profile.name}
fill
className="object-cover"
/>
</div>
</motion.div>
<motion.div
initial={{ x: 50, opacity: 0 }}
whileInView={{ x: 0, opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="space-y-6"
>
<p className="text-lg text-gray-700 leading-relaxed">
{profile.bio}
</p>
<div>
<h3 className="text-xl font-semibold text-primary-600 mb-2">
Languages
</h3>
<ul className="list-disc list-inside text-gray-700 space-y-1">
{profile.languages.map((lang, idx) => (
<li key={idx}>{lang}</li>
))}
</ul>
</div>
<div>
<h3 className="text-xl font-semibold text-primary-600 mb-2">
Contact
</h3>
<div className="space-y-2 text-gray-700">
<p>
<strong>Email:</strong>{" "}
<a
href={`mailto:${profile.email}`}
className="text-primary-600 hover:underline"
>
{profile.email}
</a>
</p>
<p>
<strong>Phone:</strong> {profile.phone}
</p>
<p>
<strong>LinkedIn:</strong>{" "}
<a
href={profile.linkedin}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:underline"
>
{profile.linkedin}
</a>
</p>
</div>
</div>
</motion.div>
</div>
</div>
</section>
);
}
- Step 2: Commit About
git add src/components/About.tsx
git commit -m "feat: add About section component"
Task 7: Build Experience (Timeline) component
Files:
-
Create:
src/components/Experience.tsx -
Step 1: Create Timeline with vertical line and nodes
"use client";
import { motion } from "framer-motion";
import { Experience } from "@/data/experience";
export default function Experience() {
return (
<section id="experience" className="py-20 bg-gray-50">
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="text-center mb-16"
>
<h2 className="text-4xl font-heading font-bold text-primary-700 mb-4">
Experience
</h2>
<div className="w-24 h-1 bg-accent-500 mx-auto" />
</motion.div>
<div className="relative">
{/* Vertical line */}
<div className="absolute left-8 md:left-1/2 top-0 bottom-0 w-0.5 bg-primary-300 transform md:-translate-x-1/2" />
{experience.map((exp, index) => (
<motion.div
key={exp.id}
initial={{ opacity: 0, x: index % 2 === 0 ? -30 : 30 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: index * 0.1 }}
className={`relative flex items-center mb-12 ${
index % 2 === 0 ? "md:flex-row" : "md:flex-row-reverse"
}`}
>
{/* Timeline node */}
<div className="absolute left-6 md:left-1/2 w-5 h-5 bg-accent-500 rounded-full border-4 border-white shadow-lg transform -translate-x-1/2 z-10" />
{/* Content */}
<div className={`ml-16 md:ml-0 md:w-1/2 ${index % 2 === 0 ? "md:pr-12" : "md:pl-12"}`}>
<div className="bg-white p-6 rounded-xl shadow-lg">
<h3 className="text-2xl font-bold text-primary-700">
{exp.role}
</h3>
<p className="text-lg text-accent-600 font-medium mb-1">
{exp.company}
</p>
<p className="text-sm text-gray-500 mb-4">
{exp.startDate} - {exp.endDate}
</p>
<ul className="list-disc list-inside text-gray-700 space-y-2">
{exp.description.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</div>
</div>
</motion.div>
))}
</div>
</div>
</section>
);
}
- Step 2: Commit Experience
git add src/components/Experience.tsx
git commit -m "feat: add Experience timeline component"
Task 8: Build Contact section with form
Files:
-
Create:
src/components/Contact.tsx -
Step 1: Create Contact form (no backend, just UI + success animation)
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { profile } from "@/data/profile";
export default function Contact() {
const [submitted, setSubmitted] = useState(false);
const [formData, setFormData] = useState({
name: "",
email: "",
message: "",
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Simulate submission
setSubmitted(true);
setTimeout(() => {
setSubmitted(false);
setFormData({ name: "", email: "", message: "" });
}, 3000);
};
return (
<section id="contact" className="py-20 bg-white">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="text-center mb-12"
>
<h2 className="text-4xl font-heading font-bold text-primary-700 mb-4">
Get In Touch
</h2>
<div className="w-24 h-1 bg-accent-500 mx-auto" />
</motion.div>
<div className="grid md:grid-cols-2 gap-12">
{/* Contact Info */}
<motion.div
initial={{ x: -30, opacity: 0 }}
whileInView={{ x: 0, opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="space-y-6"
>
<div>
<h3 className="text-xl font-semibold text-primary-600 mb-2">
Contact Information
</h3>
<div className="space-y-4">
<p>
<strong>Email:</strong>
<br />
<a
href={`mailto:${profile.email}`}
className="text-primary-600 hover:underline"
>
{profile.email}
</a>
</p>
<p>
<strong>Phone:</strong>
<br />
{profile.phone}
</p>
<p>
<strong>LinkedIn:</strong>
<br />
<a
href={profile.linkedin}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:underline"
>
{profile.linkedin}
</a>
</p>
</div>
</div>
<div className="pt-8 border-t border-gray-200">
<p className="text-gray-600 italic">
I'd love to hear from you! Whether you have a project in mind,
want to collaborate, or just want to say hi, feel free to reach
out.
</p>
</div>
</motion.div>
{/* Form */}
<motion.div
initial={{ x: 30, opacity: 0 }}
whileInView={{ x: 0, opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Name
</label>
<input
type="text"
id="name"
required
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition"
placeholder="Your name"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
id="email"
required
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition"
placeholder="your@email.com"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-1">
Message
</label>
<textarea
id="message"
rows={5}
required
value={formData.message}
onChange={(e) =>
setFormData({ ...formData, message: e.target.value })
}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition resize-none"
placeholder="Your message..."
/>
</div>
<button
type="submit"
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-all shadow-md hover:shadow-lg"
>
Send Message
</button>
</form>
{/* Success confetti animation */}
<AnimatePresence>
{submitted && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
className="fixed inset-0 flex items-center justify-center bg-black/50 z-50"
>
<motion.div
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
className="bg-white p-8 rounded-2xl shadow-2xl text-center"
>
<h3 className="text-2xl font-bold text-primary-600 mb-2">
Message Sent!
</h3>
<p className="text-gray-600">
Thanks for reaching out. I'll get back to you soon.
</p>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
</div>
</section>
);
}
- Step 2: Commit Contact
git add src/components/Contact.tsx
git commit -m "feat: add Contact section with form and success animation"
Task 9: Build ProjectCard component
Files:
-
Create:
src/components/ProjectCard.tsx -
Step 1: Create ProjectCard
"use client";
import { motion } from "framer-motion";
import Image from "next/image";
import { Project } from "@/data/projects";
export default function ProjectCard({ project }: { project: Project }) {
return (
<motion.article
whileHover={{ y: -8, scale: 1.02 }}
className="bg-white rounded-2xl shadow-lg overflow-hidden border border-gray-100 hover:border-primary-200 transition-all"
>
{project.image && (
<div className="relative h-48 w-full">
<Image
src={project.image}
alt={project.title}
fill
className="object-cover"
/>
</div>
)}
<div className="p-6">
<h3 className="text-xl font-bold text-primary-700 mb-2">
{project.title}
</h3>
<p className="text-gray-600 mb-4">{project.description}</p>
<div className="flex flex-wrap gap-2 mb-4">
{project.tech.map((t) => (
<span
key={t}
className="px-3 py-1 bg-accent-100 text-accent-800 rounded-full text-sm font-medium"
>
{t}
</span>
))}
</div>
<div className="flex gap-4">
{project.demoUrl && (
<a
href={project.demoUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-800 font-medium"
>
Live Demo →
</a>
)}
{project.githubUrl && (
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="text-gray-700 hover:text-gray-900 font-medium"
>
GitHub →
</a>
)}
</div>
</div>
</motion.article>
);
}
- Step 2: Commit ProjectCard
git add src/components/ProjectCard.tsx
git commit -m "feat: add ProjectCard component"
Task 10: Build BlogCard component
Files:
-
Create:
src/components/BlogCard.tsx -
Step 1: Create BlogCard
import Link from "next/link";
import { BlogPost } from "@/data/blogPosts";
export default function BlogCard({ post }: { post: BlogPost }) {
return (
<article className="bg-white rounded-xl shadow-md overflow-hidden hover:shadow-xl transition-shadow border border-gray-100">
{post.image && (
<div className="relative h-48 w-full">
<img
src={post.image}
alt={post.title}
className="object-cover w-full h-full"
/>
</div>
)}
<div className="p-6">
<p className="text-sm text-gray-500 mb-2">{post.date}</p>
<h3 className="text-xl font-bold text-primary-700 mb-2">
{post.title}
</h3>
<p className="text-gray-600 mb-4">{post.excerpt}</p>
<div className="flex flex-wrap gap-2 mb-4">
{post.tags.map((tag) => (
<span
key={tag}
className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-medium"
>
#{tag}
</span>
))}
</div>
<Link
href={`/my-corner/${post.slug}`}
className="text-primary-600 hover:text-primary-800 font-medium"
>
Read more →
</Link>
</div>
</article>
);
}
- Step 2: Commit BlogCard
git add src/components/BlogCard.tsx
git commit -m "feat: add BlogCard component"
Task 11: Compose Home page
Files:
-
Modify:
app/page.tsx(replace default) -
Modify:
app/layout.tsx(to include TetEffects and Header/Footer) -
Step 1: Update
app/layout.tsxto include global wrappers
// Add imports
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import TetEffects from "@/components/TetEffects";
// Inside RootLayout, wrap children:
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${inter.variable} ${playfair.variable} font-body`}>
<TetEffects />
<Header />
<main>{children}</main>
<Footer />
</body>
</html>
);
}
- Step 2: Rewrite
app/page.tsxto assemble Home sections
import Hero from "@/components/Hero";
import About from "@/components/About";
import Experience from "@/components/Experience";
import Contact from "@/components/Contact";
export default function Home() {
return (
<>
<Hero />
<About />
<Experience />
<Contact />
</>
);
}
- Step 3: Commit Home assembly
git add app/layout.tsx app/page.tsx
git commit -m "feat: compose Home page with all sections"
Task 12: Build Projects page
Files:
-
Create:
app/projects/page.tsx -
Step 1: Create Projects page with grid
import { motion } from "framer-motion";
import { projects } from "@/data/projects";
import ProjectCard from "@/components/ProjectCard";
export default function ProjectsPage() {
return (
<div className="min-h-screen bg-gray-50 pt-24 pb-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="text-center mb-16"
>
<h1 className="text-4xl font-heading font-bold text-primary-700 mb-4">
Projects
</h1>
<div className="w-24 h-1 bg-accent-500 mx-auto" />
</motion.div>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-8">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
</div>
</div>
);
}
- Step 2: Commit Projects page
git add app/projects/page.tsx
git commit -m "feat: add Projects page"
Task 13: Build My Corner page (blog list)
Files:
-
Create:
app/my-corner/page.tsx -
Step 1: Create My Corner list page
import { motion } from "framer-motion";
import { blogPosts } from "@/data/blogPosts";
import BlogCard from "@/components/BlogCard";
export default function MyCornerPage() {
return (
<div className="min-h-screen bg-white pt-24 pb-12">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="text-center mb-16"
>
<h1 className="text-4xl font-heading font-bold text-primary-700 mb-4">
My Corner
</h1>
<p className="text-lg text-gray-600">
Personal stories, memories, and reflections
</p>
</motion.div>
<div className="space-y-8">
{blogPosts.map((post) => (
<BlogCard key={post.slug} post={post} />
))}
</div>
</div>
</div>
);
}
- Step 2: Commit My Corner page
git add app/my-corner/page.tsx
git commit -m "feat: add My Corner blog list page"
Task 14: Build single blog post page
Files:
-
Create:
app/my-corner/[slug]/page.tsx -
Step 1: Create dynamic route with markdown rendering (or plain HTML for simplicity; we can add remark later)
import { notFound } from "next/navigation";
import { blogPosts } from "@/data/blogPosts";
import Link from "next/link";
import { motion } from "framer-motion";
export function generateStaticParams() {
return blogPosts.map((post) => ({
slug: post.slug,
}));
}
export default function BlogPostPage({
params,
}: {
params: { slug: string };
}) {
const post = blogPosts.find((p) => p.slug === params.slug);
if (!post) {
notFound();
}
return (
<div className="min-h-screen bg-white pt-24 pb-12">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<Link
href="/my-corner"
className="inline-block text-primary-600 hover:text-primary-800 mb-6"
>
← Back to My Corner
</Link>
<article>
<h1 className="text-4xl font-heading font-bold text-primary-700 mb-4">
{post.title}
</h1>
<p className="text-gray-500 mb-8">{post.date}</p>
{post.image && (
<div className="relative h-64 w-full mb-8 rounded-2xl overflow-hidden">
<img
src={post.image}
alt={post.title}
className="object-cover w-full h-full"
/>
</div>
)}
<div className="prose prose-lg max-w-none text-gray-700">
{/* Simple line-based rendering; for richer markdown, add 'react-markdown' */}
{post.content.split("\n").map((line, i) => {
if (line.startsWith("# ")) {
return (
<h1 key={i} className="text-3xl font-bold my-4">
{line.slice(2)}
</h1>
);
}
if (line.startsWith("## ")) {
return (
<h2 key={i} className="text-2xl font-bold my-4">
{line.slice(3)}
</h2>
);
}
if (line.startsWith("- ")) {
return (
<li key={i} className="ml-6 list-disc">
{line.slice(2)}
</li>
);
}
if (line.trim() === "") {
return <br key={i} />;
}
return (
<p key={i} className="mb-4">
{line}
</p>
);
})}
</div>
</article>
</motion.div>
</div>
</div>
);
}
- Step 2: Commit dynamic blog page
git add app/my-corner/[slug]/page.tsx
git commit -m "feat: add dynamic blog post page"
Task 15: Add scroll animations to Home sections
Files:
-
src/components/Hero.tsx(already has Framer Motion) -
src/components/About.tsx -
src/components/Experience.tsx -
src/components/Contact.tsx -
app/page.tsx(wrap sections in motion.div with viewport) -
Step 1: Wrap each section in
motion.sectionwithwhileInViewfor scroll reveal (already added in About, Experience, Contact from previous tasks). Verify smooth behavior. -
Step 2: Add stagger to timeline items in Experience (already in loop with delay)
-
Step 3: Ensure Hero animates on load (already done)
-
Step 4: Commit scroll animations
git add src/components/Hero.tsx src/components/About.tsx src/components/Experience.tsx src/components/Contact.tsx
git commit -m "feat: add scroll-triggered animations with Framer Motion"
Task 16: Responsive styling and mobile menu
Files:
-
src/components/Header.tsx(already responsive) -
All pages and components use Tailwind responsive classes
-
Step 1: Verify Header mobile menu works on small screens (already built)
-
Step 2: Ensure Home sections stack properly on mobile (Tailwind responsive classes already used)
-
Step 3: Verify Projects grid:
sm:grid-cols-2 lg:grid-cols-3(already set) -
Step 4: Test on multiple viewports mentally or suggest manual testing
-
Step 5: Commit responsive pass
git add .
git commit -m "style: ensure responsive layouts across all pages"
Task 17: Test build locally
Files:
-
All project files
-
Step 1: Run development server
npm run dev
Check for console errors, broken routes, missing images.
-
Step 2: Test each route:
//projects/my-corner/my-corner/journey-into-ai(example)
-
Step 3: Test contact form submission (mock)
-
Step 4: Verify animations smooth (60fps expected)
-
Step 5: Fix any issues found
-
Step 6: Build for production
npm run build
Ensure build completes without errors.
- Step 7: Commit final fixes
git add .
git commit -m "fix: address build/test issues and finalize"
Task 18: Prepare for Vercel deployment
Files:
-
Optionally create
vercel.jsonif custom config needed (usually not) -
Update
README.mdwith deployment instructions -
Step 1: Create README.md
# Trí Vĩ Portfolio
Personal portfolio built with Next.js 14, Tailwind CSS, Framer Motion, featuring a traditional Tết theme.
## Features
- Responsive design (mobile, tablet, desktop)
- Smooth animations with Framer Motion
- Traditional Tết theme (falling petals, fireworks, red/gold colors)
- Multi-page routing: Home, Projects, My Corner (blog)
- Contact form with success animation
## Tech Stack
- Next.js 14 (App Router)
- TypeScript
- Tailwind CSS
- Framer Motion
## Getting Started
1. Clone the repository
2. Install dependencies: \`npm install\`
3. Run the development server: \`npm run dev\`
4. Open [http://localhost:3000](http://localhost:3000) in your browser
## Build for Production
\`\`\`bash
npm run build
npm start
\`\`\`
## Deploy on Vercel
[Import this repository on Vercel](https://vercel.com/new) and deploy automatically.
- Step 2: Commit README
git add README.md
git commit -m "docs: add README with deploy instructions"
- Step 3: Push to remote (if configured) or instruct user to deploy via Vercel CLI
Acceptance Checklist
- Next.js project initializes and runs locally
- Tailwind colors configured (primary red, accent gold)
- Google Fonts loaded (Playfair Display, Inter)
- Header responsive (desktop nav + mobile hamburger)
- Hero section with avatar, gradient, CTA buttons
- About section with bio, languages, contact info
- Experience timeline with 4 positions, alternating sides, on-scroll animations
- Contact form with fields (name, email, message) and success modal
- TetEffects component: falling petals canvas + subtle sparkles
- Projects page with grid of project cards (hover effects, tech badges)
- My Corner page with blog cards list
- Blog post dynamic page renders content
- Scroll animations on all Home sections (fade/slide)
- Responsive on mobile (<768px), tablet (768-1024px), desktop (>1024px)
- No console errors or TypeScript warnings
- Production build succeeds (
npm run build) - README with instructions
- Ready for Vercel deployment
Spec referenced: docs/superpowers/specs/2026-03-31-portfolio-tet-theme-design.md
Total estimated tasks: ~18 major tasks, broken into 2-5 minute steps each. With ClawTeam parallelization, this can be completed quickly.
Notes for implementer:
- Use
clawteam spawnto assign tasks to workers. - Each worker should get one component/page at a time.
- Review between tasks (two-stage code review).
- Keep commits small and descriptive.
- Ensure data is accurately transferred from CV.
- Polish animations to be smooth (60fps).
- Test responsiveness thoroughly.
- Keep the Tết theme elegant and not overwhelming.
- Do not simplify; deliver a complete, polished product ready to deploy.