fix: hero button localization/hover, lang switcher dark mode, services grid, testimonials carousel, project modal, footer gap, back-to-top

This commit is contained in:
parsa aghaei 2026-06-29 08:57:34 +03:30
parent 9797b71f55
commit 470036d6b9
9 changed files with 242 additions and 80 deletions

View File

@ -128,9 +128,9 @@ export default function Hero({ dict }: HeroProps) {
>
<a
href="#contact"
className="group relative inline-flex items-center justify-center px-8 py-3 font-bold text-white transition-all duration-200 bg-primary rounded-full hover:bg-primary-foreground hover:text-primary active:scale-95"
className="group relative inline-flex items-center justify-center whitespace-nowrap px-8 py-3 font-bold text-white transition-all duration-200 bg-primary rounded-full hover:brightness-110 hover:scale-105 active:scale-95"
>
<span className="relative z-10">Let's Work Together</span>
<span className="relative z-10">{dict.website.homePage.top.cta}</span>
<div className="absolute inset-0 rounded-full bg-primary blur-lg opacity-50 group-hover:opacity-100 transition-opacity"></div>
</a>
</motion.div>

View File

@ -7,12 +7,12 @@ import {
ProjectResponse,
} from "@/app/types/types";
import { getCookie } from "cookies-next";
import Link from "next/link";
import React, { useEffect, useState, useMemo } from "react";
import React, { useEffect, useState, useMemo, useCallback } from "react";
import Skeleton from "react-loading-skeleton";
import "react-loading-skeleton/dist/skeleton.css";
import { motion, AnimatePresence } from "framer-motion";
import Image from "next/image";
import { X, ChevronLeft, ChevronRight } from "lucide-react";
interface MyProjectsProps {
dict: {
@ -35,6 +35,7 @@ export default function Projects({ dict }: MyProjectsProps) {
const [projects, setProjects] = useState<Project[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string>("");
const [loading, setLoading] = useState(true);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const locale = getCookie("selectedLocale") || getCookie("defaultLocale");
useEffect(() => {
@ -97,6 +98,29 @@ export default function Projects({ dict }: MyProjectsProps) {
return groupedProjects[selectedCategory]?.slice(0, MAX_PROJECTS) || [];
}, [selectedCategory, projects, groupedProjects, locale]);
const currentModalIndex = useMemo(
() => filteredProjects.findIndex((p) => p.id === selectedProject?.id),
[filteredProjects, selectedProject],
);
const handlePrev = useCallback(() => {
if (currentModalIndex > 0) {
setSelectedProject(filteredProjects[currentModalIndex - 1]);
}
}, [currentModalIndex, filteredProjects]);
const handleNext = useCallback(() => {
if (currentModalIndex < filteredProjects.length - 1) {
setSelectedProject(filteredProjects[currentModalIndex + 1]);
}
}, [currentModalIndex, filteredProjects]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Escape") setSelectedProject(null);
if (e.key === "ArrowLeft") handlePrev();
if (e.key === "ArrowRight") handleNext();
}, [handlePrev, handleNext]);
const renderSkeletons = (count: number) => (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 w-full">
{Array.from({ length: count }, (_, index) => (
@ -161,7 +185,7 @@ export default function Projects({ dict }: MyProjectsProps) {
>
<AnimatePresence mode="popLayout">
{filteredProjects.length > 0
? filteredProjects.map((project, index) => (
? filteredProjects.map((project) => (
<motion.div
key={project.id}
layout
@ -169,9 +193,9 @@ export default function Projects({ dict }: MyProjectsProps) {
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.3 }}
className="group relative rounded-3xl overflow-hidden glass-panel"
onClick={() => setSelectedProject(project)}
className="group relative rounded-3xl overflow-hidden glass-panel cursor-pointer"
>
{/* Project Image Gallery */}
<div className="relative h-64 w-full overflow-hidden">
{project.images.map((image, idx) => (
<Image
@ -182,26 +206,19 @@ export default function Projects({ dict }: MyProjectsProps) {
className={`object-cover transition-transform duration-500 group-hover:scale-110 ${
idx === 0 ? "opacity-100" : "opacity-0"
}`}
// In a real app, we would implement a slider here
/>
))}
{/* Hover Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-all duration-300 flex flex-col justify-end p-6 text-white">
<span className="text-xs font-mono text-primary mb-2 uppercase tracking-widest">
{project.category}
</span>
<Link
href={process.env.NEXT_PUBLIC_BACK_URL + "/projects/" + project.id}
target="_blank"
className="text-lg font-bold underline underline-offset-4 hover:text-primary transition-colors"
>
View Project
</Link>
<span className="text-lg font-bold">
{locale === "fa" ? " مشاهده جزییات" : "View Details"}
</span>
</div>
</div>
{/* Project Details */}
<div className="p-6">
<h5 className="text-sm font-mono text-primary mb-1">
{project.category}
@ -231,6 +248,89 @@ export default function Projects({ dict }: MyProjectsProps) {
)}
</AnimatePresence>
</motion.div>
<AnimatePresence>
{selectedProject && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={() => setSelectedProject(null)}
onKeyDown={handleKeyDown}
tabIndex={0}
>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
transition={{ duration: 0.3 }}
onClick={(e) => e.stopPropagation()}
className="relative w-full max-w-3xl max-h-[90vh] overflow-y-auto rounded-3xl bg-background border border-border shadow-2xl"
>
<button
onClick={() => setSelectedProject(null)}
className="absolute top-4 end-4 z-10 w-10 h-10 rounded-full bg-background/80 backdrop-blur-sm border border-border flex items-center justify-center text-foreground hover:text-primary transition-colors"
aria-label="Close modal"
>
<X className="w-5 h-5" />
</button>
<div className="relative h-72 md:h-96 w-full">
{selectedProject.images.map((image, idx) => (
<Image
key={idx}
src={process.env.NEXT_PUBLIC_BACK_URL + "/images/projects/" + image.src}
alt={`Image for ${selectedProject.title}`}
fill
className={`object-cover ${idx === 0 ? "opacity-100" : "opacity-0"}`}
/>
))}
</div>
<div className="p-8">
<span className="text-xs font-mono text-primary uppercase tracking-widest">
{selectedProject.category}
</span>
<h3 className="text-3xl font-bold text-foreground mt-2 mb-4">
{selectedProject.title}
</h3>
<p className="text-muted-foreground leading-relaxed">
{locale === "fa"
? "توضیحات کامل پروژه در این بخش نمایش داده می‌شود."
: "Full project description will be displayed here."}
</p>
</div>
<div className="flex items-center justify-between px-8 pb-8">
<button
onClick={handlePrev}
disabled={currentModalIndex <= 0}
className="flex items-center gap-2 px-4 py-2 rounded-full bg-muted text-foreground hover:text-primary transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-5 h-5" />
<span className="text-sm font-medium">
{locale === "fa" ? "قبلی" : "Previous"}
</span>
</button>
<span className="text-sm text-muted-foreground">
{currentModalIndex + 1} / {filteredProjects.length}
</span>
<button
onClick={handleNext}
disabled={currentModalIndex >= filteredProjects.length - 1}
className="flex items-center gap-2 px-4 py-2 rounded-full bg-muted text-foreground hover:text-primary transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
<span className="text-sm font-medium">
{locale === "fa" ? "بعدی" : "Next"}
</span>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</section>
);
}

View File

@ -37,11 +37,11 @@ export default function SelectLanguage({
)}
<ul
className={`absolute start-0 z-20 flex w-full flex-col items-center gap-2 rounded-lg border bg-white py-3 transition-all ${isOpen ? "top-[50px] scale-100" : "top-0 scale-0"}`}
className={`absolute start-0 z-20 flex w-full flex-col items-center gap-2 rounded-lg border bg-white dark:bg-zinc-900 dark:text-white text-black py-3 transition-all ${isOpen ? "top-[50px] scale-100" : "top-0 scale-0"}`}
>
<Link
href=""
className="w-[70%] border-b-2 border-[rgba(0,0,0,0)] pb-1 text-center hover:cursor-pointer hover:border-[#FD6F00]"
className="w-[70%] border-b-2 border-[rgba(0,0,0,0)] dark:text-white text-black pb-1 text-center hover:cursor-pointer hover:border-[#FD6F00] dark:hover:text-[#FD6F00]"
onClick={(e) => {
e.preventDefault();
setCookie("selectedLocale", "fa"), router.refresh();
@ -52,7 +52,7 @@ export default function SelectLanguage({
</Link>
<Link
href=""
className="w-[70%] border-b-2 border-[rgba(0,0,0,0)] pb-1 text-center hover:cursor-pointer hover:border-[#FD6F00]"
className="w-[70%] border-b-2 border-[rgba(0,0,0,0)] dark:text-white text-black pb-1 text-center hover:cursor-pointer hover:border-[#FD6F00] dark:hover:text-[#FD6F00]"
onClick={(e) => {
e.preventDefault();
setCookie("selectedLocale", "en"), router.refresh();

View File

@ -103,9 +103,9 @@ export default function Services({ dict }: ServicesProps) {
</motion.p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 w-full">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto w-full">
{loading
? Array.from({ length: 4 }).map((_, index) =>
? Array.from({ length: 3 }).map((_, index) =>
renderServiceCard(undefined, index),
)
: services.map((service, index) => renderServiceCard(service, index))}

View File

@ -1,9 +1,9 @@
"use client";
import { Testimonial } from "@/app/types/types";
import React from "react";
import React, { useState, useRef } from "react";
import { motion } from "framer-motion";
import { Quote } from "lucide-react";
import { Quote, ChevronLeft, ChevronRight } from "lucide-react";
import Image from "next/image";
interface TestimonialsProps {
@ -58,6 +58,19 @@ const testimonials: Testimonial[] = [
];
export default function Testimonials({ dict }: TestimonialsProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const scrollRef = useRef<HTMLDivElement>(null);
const maxIndex = Math.max(0, testimonials.length - 3);
const scrollTo = (index: number) => {
const idx = Math.max(0, Math.min(index, maxIndex));
setCurrentIndex(idx);
scrollRef.current?.children[idx]?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "start" });
};
const handlePrev = () => scrollTo(currentIndex - 1);
const handleNext = () => scrollTo(currentIndex + 1);
return (
<section
className="relative flex w-full max-w-[1200px] flex-col items-center py-20"
@ -84,52 +97,97 @@ export default function Testimonials({ dict }: TestimonialsProps) {
</motion.p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 w-full">
{testimonials.map((testimonial, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
viewport={{ once: true }}
whileHover={{ y: -10 }}
className="relative p-8 rounded-3xl glass-panel flex flex-col h-full"
>
<div className="absolute -top-6 -start-6 w-12 h-12 bg-primary rounded-full flex items-center justify-center text-white shadow-lg">
<Quote className="w-6 h-6" />
</div>
<div className="flex-grow">
<p className="text-muted-foreground leading-relaxed mb-8 italic">
"{testimonial.quote}"
</p>
</div>
<div className="relative w-full">
<div
ref={scrollRef}
className="flex gap-8 overflow-x-auto snap-x snap-mandatory scrollbar-none pb-4"
style={{ scrollbarWidth: "none", msOverflowStyle: "none" }}
>
{testimonials.map((testimonial, index) => (
<div
key={index}
className="min-w-[100%] md:min-w-[calc(50%-1rem)] lg:min-w-[calc(33.333%-1.1rem)] snap-start flex-shrink-0"
>
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
viewport={{ once: true }}
whileHover={{ y: -10 }}
className="relative p-8 rounded-3xl glass-panel flex flex-col h-full"
>
<div className="absolute -top-6 -start-6 w-12 h-12 bg-primary rounded-full flex items-center justify-center text-white shadow-lg">
<Quote className="w-6 h-6" />
</div>
<div className="flex-grow">
<p className="text-muted-foreground leading-relaxed mb-8 italic">
"{testimonial.quote}"
</p>
</div>
<div className="flex items-center gap-4 mt-auto pt-6 border-t border-white/10">
<div className="relative w-12 h-12 overflow-hidden rounded-full border-2 border-primary/50">
<Image
src={testimonial.image}
alt={testimonial.name}
fill
className="object-cover"
/>
</div>
<div className="flex flex-col">
<h5 className="font-bold text-foreground leading-none mb-1">
{testimonial.name}
</h5>
<p className="text-xs text-muted-foreground leading-tight">
{testimonial.position}
</p>
</div>
<div className="ms-auto bg-primary/10 text-primary text-[10px] font-bold px-2 py-1 rounded-md border border-primary/20">
Verified
</div>
<div className="flex items-center gap-4 mt-auto pt-6 border-t border-white/10">
<div className="relative w-12 h-12 overflow-hidden rounded-full border-2 border-primary/50 flex-shrink-0">
<Image
src={testimonial.image}
alt={testimonial.name}
fill
className="object-cover"
/>
</div>
<div className="flex flex-col min-w-0">
<h5 className="font-bold text-foreground leading-none mb-1 truncate">
{testimonial.name}
</h5>
<p className="text-xs text-muted-foreground leading-tight truncate">
{testimonial.position}
</p>
</div>
<div className="ms-auto bg-primary/10 text-primary text-[10px] font-bold px-2 py-1 rounded-md border border-primary/20 flex-shrink-0">
Verified
</div>
</div>
</motion.div>
</div>
</motion.div>
))}
))}
</div>
{testimonials.length > 3 && (
<>
<button
onClick={handlePrev}
disabled={currentIndex === 0}
className="absolute -left-4 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-background border border-border shadow-lg flex items-center justify-center text-foreground hover:text-primary transition-colors disabled:opacity-30 disabled:cursor-not-allowed z-10"
aria-label="Previous testimonials"
>
<ChevronLeft className="w-5 h-5" />
</button>
<button
onClick={handleNext}
disabled={currentIndex >= maxIndex}
className="absolute -right-4 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-background border border-border shadow-lg flex items-center justify-center text-foreground hover:text-primary transition-colors disabled:opacity-30 disabled:cursor-not-allowed z-10"
aria-label="Next testimonials"
>
<ChevronRight className="w-5 h-5" />
</button>
<div className="flex items-center justify-center gap-2 mt-8">
{Array.from({ length: maxIndex + 1 }).map((_, i) => (
<button
key={i}
onClick={() => scrollTo(i)}
className={`w-2.5 h-2.5 rounded-full transition-all duration-300 ${
i === currentIndex
? "bg-primary w-6"
: "bg-muted-foreground/30 hover:bg-muted-foreground/50"
}`}
aria-label={`Go to testimonial group ${i + 1}`}
/>
))}
</div>
</>
)}
</div>
</section>
);
}

View File

@ -36,19 +36,20 @@ export default function RootLayout({
/>
<div className="scroll-watcher" id="top"></div>
<div className="absolute start-0 top-0 opacity-0" id="here-is-top"></div>
<a
href="/#here-is-top"
className="scroll-to-top fixed end-5 flex h-[50px] w-[50px] items-center justify-center rounded-full border-2 bg-[rgba(0,0,0,0.05)] p-5 hover:border-[#f3a66b] sm:bottom-[100px] sm:end-10"
id="back-to-top"
>
<p className="-mb-[35px] -ms-[12px] w-full text-[50px] font-bold text-[rgb(128,128,128)]">
^
</p>
</a>
<Header />
<NextTopLoader color="rgb(253 111 0)" showAtBottom={false} />
<div className="main my-10 flex w-full justify-center">{children}</div>
<div className="main flex w-full justify-center">{children}</div>
<a
href="/#here-is-top"
className="fixed bottom-8 end-8 z-40 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-white shadow-lg shadow-primary/30 transition-all duration-300 hover:scale-110 hover:shadow-xl hover:shadow-primary/40 active:scale-95"
id="back-to-top"
aria-label="Back to top"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
</svg>
</a>
<Footer />
</>

View File

@ -6,7 +6,8 @@
"name": "Parsa Aghayi",
"profession": "Senior Frontend Engineer & Web Architect",
"roll": "Turning complex engineering challenges into clean, maintainable code.",
"descriptionAboutMe": "Crafting high-performance web applications, scalable React/Next.js architectures, and seamless backend integrations. Turning complex engineering challenges into clean, maintainable code."
"descriptionAboutMe": "Crafting high-performance web applications, scalable React/Next.js architectures, and seamless backend integrations. Turning complex engineering challenges into clean, maintainable code.",
"cta": "Let's Work Together"
},
"services": {
"title": "services",

View File

@ -6,7 +6,8 @@
"name": "پارس آقایی",
"profession": "ارشد توسعه فرانت‌اند و معمار راهکارهای وب",
"roll": "تمرکز من بر خلق رابط‌های کاربری بسیار بهینه و توسعه کامپوننت‌های مقیاس‌پذیر است.",
"descriptionAboutMe": "با بیش از ۱۰ سال تجربه در دنیای توسعه وب، مسیر کاری من ترکیبی از مهندسی دقیق فرانت‌اند و درک عمیق از معماری بک‌اند است. تخصص اصلی من در بازطراحی و Refactor کدهای پیچیده، بهینه‌سازی Performance پروژه‌های بزرگ Next.js و پیاده‌سازی زیرساخت‌های پایدار وب است."
"descriptionAboutMe": "با بیش از ۱۰ سال تجربه در دنیای توسعه وب، مسیر کاری من ترکیبی از مهندسی دقیق فرانت‌اند و درک عمیق از معماری بک‌اند است. تخصص اصلی من در بازطراحی و Refactor کدهای پیچیده، بهینه‌سازی Performance پروژه‌های بزرگ Next.js و پیاده‌سازی زیرساخت‌های پایدار وب است.",
"cta": "بیایید با هم کار کنیم"
},
"services": {
"title": "خدمات",

View File

@ -127,6 +127,7 @@ export type Dictionary = {
profession: string;
roll: string;
descriptionAboutMe: string;
cta: string;
};
services: {
title: string;