Files
CompanySite/src/pages/portfolio.astro
T

1221 lines
63 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
import BaseLayout from '../layouts/BaseLayout.astro';
import SEO from '../components/SEO.astro';
const projects = [
{
id: 'plnr-financing',
title: 'PLNR Financing Website',
category: 'web',
featured: true,
thumbnail: 'https://images.unsplash.com/photo-1556742049-0cfed4f6a45d?w=800&h=500&fit=crop&auto=format&q=75',
gallery: [
'https://images.unsplash.com/photo-1556742049-0cfed4f6a45d?w=1200&h=750&fit=crop&auto=format&q=75',
],
outcome: 'Onboarded 5,000+ users and processed 10,000+ transactions at 99.9% uptime.',
description: 'A comprehensive financing platform built for PLNR, enabling seamless loan processing, EMI calculations, and customer management. The platform features a modern Angular frontend with a robust Spring Boot backend and MySQL database, handling thousands of financial transactions securely.',
techStack: ['Angular', 'Spring Boot', 'MySQL', 'TailwindCSS'],
client: 'PLNR',
duration: '2 years (20222023)',
year: '2023',
liveUrl: 'https://plnr.in',
results: ['5,000+ users onboarded', '99.9% uptime achieved', '10,000+ transactions processed'],
stats: [
{ label: 'Users', value: '5,000+' },
{ label: 'Uptime', value: '99.9%' },
{ label: 'Transactions', value: '10,000+' },
],
gradient: 'from-blue-500 to-cyan-500',
fullDescription: 'A comprehensive financing platform built for PLNR, enabling seamless loan processing, EMI calculations, and customer management. The platform features a modern Angular frontend with a robust Spring Boot backend and MySQL database, handling thousands of financial transactions securely.',
challenge: 'The client needed a reliable, secure platform to process financial transactions at scale with minimal downtime and fast turnaround for loan applications.',
solution: 'Built a modern Angular frontend with Spring Boot backend and MySQL database, implementing secure transaction processing, automated EMI calculations, and a streamlined customer management workflow.',
},
{
id: 'scms-pscl',
title: 'SCMS for PSCL Bihar Government',
category: 'government',
featured: true,
thumbnail: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=800&h=500&fit=crop&auto=format&q=75',
gallery: [
'https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=1200&h=750&fit=crop&auto=format&q=75',
],
outcome: 'Cut crematorium slot booking from hours to minutes across 10+ facilities.',
description: 'Smart Crematorium Management System (SCMS) developed for Patna Smart City Limited under the Bihar Government. The system digitizes and streamlines crematorium operations including slot booking, real-time availability tracking, digital record-keeping, and automated reporting for transparent and efficient management of crematorium facilities across Bihar.',
techStack: ['Astro', 'Golang', 'PostgreSQL', 'TailwindCSS'],
client: 'PSCL, Bihar Government',
duration: '2 months (2024)',
year: '2024',
liveUrl: 'https://mokshdham.bihar.gov.in',
results: ['10+ facilities digitized', '50,000+ records managed', '99.9% system uptime'],
stats: [
{ label: 'Facilities', value: '10+' },
{ label: 'Records', value: '50,000+' },
{ label: 'Uptime', value: '99.9%' },
],
gradient: 'from-green-500 to-emerald-500',
fullDescription: 'Smart Crematorium Management System (SCMS) developed for Patna Smart City Limited under the Bihar Government. The system digitizes and streamlines crematorium operations including slot booking, real-time availability tracking, digital record-keeping, and automated reporting for transparent and efficient management of crematorium facilities across Bihar.',
challenge: 'Crematorium facilities across Bihar lacked digital infrastructure, leading to inefficient manual processes, poor record-keeping, and lack of transparency in operations.',
solution: 'Built a full-stack solution with Astro frontend and Golang backend, implementing slot booking, real-time availability tracking, digital record-keeping, and automated reporting dashboards for government oversight.',
},
{
id: 'police-management',
title: 'Police Management System',
category: 'government',
featured: true,
thumbnail: 'https://images.unsplash.com/photo-1563986768494-4dee2763ff3f?w=800&h=500&fit=crop&auto=format&q=75',
gallery: [
'https://images.unsplash.com/photo-1563986768494-4dee2763ff3f?w=1200&h=750&fit=crop&auto=format&q=75',
],
outcome: 'Digitized FIR and case tracking for 15,000+ officers across 11 districts.',
description: 'A comprehensive Police Management System developed for the Bihar State Government, deployed across 11 districts. The system handles personnel management, case tracking, FIR digitization, duty roster scheduling, and real-time reporting dashboards. Built to handle thousands of concurrent users with role-based access control and strict security compliance.',
techStack: ['Spring Boot', 'Angular', 'TailwindCSS', 'MySQL'],
client: 'Bihar Government',
duration: '4 years (20182022)',
year: '2022',
liveUrl: null,
results: ['Deployed across 11 districts', '15,000+ officers managed', '100,000+ cases tracked'],
stats: [
{ label: 'Districts', value: '11' },
{ label: 'Officers', value: '15,000+' },
{ label: 'Cases', value: '100,000+' },
],
gradient: 'from-red-500 to-orange-500',
fullDescription: 'A comprehensive Police Management System developed for the Bihar State Government, deployed across 11 districts. The system handles personnel management, case tracking, FIR digitization, duty roster scheduling, and real-time reporting dashboards. Built to handle thousands of concurrent users with role-based access control and strict security compliance.',
challenge: 'Police departments across 11 districts relied on paper-based processes for FIR filing, case tracking, and personnel management, causing delays and data inconsistencies.',
solution: 'Developed a centralized system with Spring Boot and Angular, featuring role-based access control, FIR digitization, automated duty roster scheduling, and real-time reporting dashboards accessible across all 11 districts.',
},
{
id: 'university-management-iitp',
title: 'University Management System — IIT Patna',
category: 'enterprise',
featured: true,
thumbnail: 'https://images.unsplash.com/photo-1507238691740-187a5b1d37b8?w=800&h=500&fit=crop&auto=format&q=75',
gallery: [
'https://images.unsplash.com/photo-1507238691740-187a5b1d37b8?w=1200&h=750&fit=crop&auto=format&q=75',
],
outcome: 'Unified admissions, exams, and grading for 3,000+ students and 200+ faculty.',
description: 'An end-to-end University Management System built for the Indian Institute of Technology, Patna. The platform manages student admissions, course registration, examination scheduling, grade management, faculty profiles, and administrative workflows. Designed for scalability and high availability to serve the entire IIT Patna academic community.',
techStack: ['Next.js', 'Spring Boot', 'MySQL', 'TailwindCSS'],
client: 'IIT Patna',
duration: '1 year (2023)',
year: '2023',
liveUrl: 'https://coe.iitp.ac.in',
results: ['3,000+ students served', '500+ courses managed', '200+ faculty onboarded'],
stats: [
{ label: 'Students', value: '3,000+' },
{ label: 'Courses', value: '500+' },
{ label: 'Faculty', value: '200+' },
],
gradient: 'from-purple-500 to-indigo-500',
fullDescription: 'An end-to-end University Management System built for the Indian Institute of Technology, Patna. The platform manages student admissions, course registration, examination scheduling, grade management, faculty profiles, and administrative workflows. Designed for scalability and high availability to serve the entire IIT Patna academic community.',
challenge: 'IIT Patna needed a unified platform to manage admissions, course registration, examinations, and administrative workflows, replacing fragmented legacy systems.',
solution: 'Built a scalable full-stack solution with Next.js and Spring Boot, implementing comprehensive modules for student lifecycle management, course scheduling, grade processing, and faculty administration.',
},
{
id: 'fpc-cart',
title: 'FPC Cart — E-Commerce Platform',
category: 'web',
featured: false,
thumbnail: 'https://images.unsplash.com/photo-1560518883-ce09059eeffa?w=800&h=500&fit=crop&auto=format&q=75',
gallery: [
'https://images.unsplash.com/photo-1560518883-ce09059eeffa?w=1200&h=750&fit=crop&auto=format&q=75',
],
outcome: 'Delivered sub-second page loads across a 1,000+ product catalog.',
description: 'A high-performance e-commerce platform built for Renaissance Technology Pvt Ltd. FPC Cart features a lightning-fast Astro frontend with a Golang backend, delivering an exceptional shopping experience with product catalogs, cart management, secure checkout, order tracking, and an admin dashboard for inventory and sales management.',
techStack: ['Astro', 'Golang', 'MySQL', 'TailwindCSS'],
client: 'Renaissance Technology Pvt Ltd',
duration: '3 months (2025)',
year: '2025',
liveUrl: 'https://fpvcart.com',
results: ['1,000+ products listed', '5,000+ orders processed', 'Sub-1s page load times'],
stats: [
{ label: 'Products', value: '1,000+' },
{ label: 'Orders', value: '5,000+' },
{ label: 'Load Time', value: '<1s' },
],
gradient: 'from-amber-500 to-yellow-500',
fullDescription: 'A high-performance e-commerce platform built for Renaissance Technology Pvt Ltd. FPC Cart features a lightning-fast Astro frontend with a Golang backend, delivering an exceptional shopping experience with product catalogs, cart management, secure checkout, order tracking, and an admin dashboard for inventory and sales management.',
challenge: 'The client needed a fast, scalable e-commerce platform that could handle large product catalogs while maintaining sub-second page load times.',
solution: 'Built with Astro for a lightning-fast frontend and Golang for a high-performance backend, implementing efficient product catalog management, secure checkout flows, and a comprehensive admin dashboard.',
},
{
id: 'pophealth',
title: 'Pophealth — Health Sessions App',
category: 'mobile',
featured: false,
thumbnail: 'https://images.unsplash.com/photo-1576091160399-112ba8d25d1f?w=800&h=500&fit=crop&auto=format&q=75',
gallery: [
'https://images.unsplash.com/photo-1576091160399-112ba8d25d1f?w=1200&h=750&fit=crop&auto=format&q=75',
],
outcome: 'Powered 10,000+ offline-first health sessions for 100+ providers.',
description: 'A mobile health application built with Expo (React Native) and Spring Boot, designed to facilitate health session management, appointment booking, patient tracking, and wellness program coordination. The app works offline-first with seamless sync, providing healthcare providers a modern tool to manage patient sessions and health data securely.',
techStack: ['Expo (React Native)', 'Spring Boot', 'PostgreSQL'],
client: 'Healthcare Startup',
duration: '3 months (2025)',
year: '2025',
liveUrl: null,
results: ['10,000+ sessions managed', '100+ providers onboarded', '4.8-star user rating'],
stats: [
{ label: 'Sessions', value: '10,000+' },
{ label: 'Providers', value: '100+' },
{ label: 'Rating', value: '4.8★' },
],
gradient: 'from-pink-500 to-rose-500',
fullDescription: 'A mobile health application built with Expo (React Native) and Spring Boot, designed to facilitate health session management, appointment booking, patient tracking, and wellness program coordination. The app works offline-first with seamless sync, providing healthcare providers a modern tool to manage patient sessions and health data securely.',
challenge: 'Healthcare providers needed a mobile-first solution to manage patient sessions and health data, with offline capability for areas with poor connectivity.',
solution: 'Developed an Expo (React Native) app with offline-first architecture and seamless sync, backed by Spring Boot and PostgreSQL for secure health data management and appointment coordination.',
},
];
// Collect all unique tech tags for the technology filter
const allTechs = [...new Set(projects.flatMap(p => p.techStack))].sort();
const featuredProjects = projects.filter(p => p.featured);
---
<BaseLayout
title="Portfolio — Case Studies & Projects"
description="Explore WorkRoot IT Solutions' portfolio: 20+ successful projects across web, mobile, ERP & government IT. From PLNR Financing to Bihar Government systems. Real results: 100% client satisfaction, 99.9% uptime."
>
<SEO
slot="head"
type="WebPage"
breadcrumbs={[
{ name: 'Home', url: '/' },
{ name: 'Portfolio', url: '/portfolio' }
]}
/>
<!-- ItemList structured data for portfolio projects -->
<script slot="head" type="application/ld+json" set:html={JSON.stringify({
"@context": "https://schema.org",
"@type": "ItemList",
"name": "WorkRoot IT Solutions — Project Portfolio",
"description": "Case studies and projects delivered by WorkRoot IT Solutions across web development, mobile apps, government, and enterprise systems.",
"url": "https://workroot.in/portfolio",
"numberOfItems": projects.length,
"itemListElement": projects.map((project, index) => ({
"@type": "ListItem",
"position": index + 1,
"name": project.title,
"description": project.description,
"url": `https://workroot.in/portfolio#${project.id}`
}))
})} />
<!-- Preload the first featured project thumbnail for faster LCP -->
<link slot="head" rel="preload" as="image" href={featuredProjects[0]?.thumbnail} />
<!-- Hero Section -->
<section class="relative overflow-hidden bg-gradient-to-br from-secondary-900 via-secondary-800 to-secondary-900 py-24 lg:py-32">
<div class="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
<div class="absolute -left-20 top-1/4 h-80 w-80 rounded-full bg-primary/20 blur-3xl animate-pulse-slower"></div>
<div class="absolute -bottom-20 right-1/4 h-96 w-96 rounded-full bg-accent/15 blur-3xl animate-pulse-slow"></div>
<div class="absolute top-0 right-0 h-64 w-64 rounded-full bg-emerald-500/10 blur-3xl"></div>
<div class="absolute inset-0 opacity-5" style="background-image: linear-gradient(rgba(255,255,255,.05) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.05) 1px, transparent 1px); background-size: 60px 60px;"></div>
</div>
<div class="container-wrapper relative">
<div class="mx-auto max-w-3xl text-center reveal-on-scroll">
<span class="inline-flex items-center gap-2 px-4 py-1.5 mb-6 text-sm font-semibold text-primary-300 bg-primary-500/10 rounded-full border border-primary-500/20">
<span class="w-1.5 h-1.5 rounded-full bg-primary-400 animate-pulse" aria-hidden="true"></span>
Our Work
</span>
<h1 class="text-4xl font-bold text-white sm:text-5xl lg:text-6xl leading-tight">
Projects That
<span class="text-transparent bg-clip-text bg-gradient-to-r from-primary-400 via-primary-300 to-accent-400"> Deliver Results</span>
</h1>
<p class="mt-6 text-lg text-secondary-300 sm:text-xl leading-relaxed">
Explore our portfolio of impactful solutions — from web platforms to AI systems — each built to solve real business challenges.
</p>
</div>
<!-- Stats bar -->
<div class="mt-14 grid grid-cols-2 sm:grid-cols-4 gap-4 lg:gap-6 reveal-on-scroll reveal-delay-2">
{[
{ value: '20+', label: 'Projects' },
{ value: '11', label: 'Districts Deployed' },
{ value: '50,000+', label: 'Records Managed' },
{ value: '8+', label: 'Years Avg Experience' },
].map((stat) => (
<div class="bg-white/5 backdrop-blur-sm rounded-xl p-4 lg:p-5 text-center border border-white/10">
<p class="text-2xl lg:text-3xl font-bold text-white">{stat.value}</p>
<p class="text-secondary-400 text-sm mt-1">{stat.label}</p>
</div>
))}
</div>
</div>
</section>
<!-- Featured Projects -->
<section class="bg-white dark:bg-secondary-900 py-16 lg:py-20">
<div class="container-wrapper">
<div class="mb-10 reveal-on-scroll">
<span class="text-primary-500 font-semibold text-sm uppercase tracking-wider">Highlighted Work</span>
<h2 class="mt-2 text-3xl font-bold text-secondary-900 dark:text-secondary-100 sm:text-4xl">Featured Projects</h2>
</div>
<div class="grid gap-8 lg:grid-cols-3 reveal-on-scroll reveal-delay-1">
{featuredProjects.map((project, idx) => (
<article
class={`featured-card group relative overflow-hidden rounded-2xl shadow-lg cursor-pointer transition-all duration-500 hover:-translate-y-2 hover:shadow-2xl ${idx === 0 ? 'lg:col-span-2 lg:row-span-1' : ''}`}
data-project-id={project.id}
tabindex="0"
role="button"
aria-label={`View case study for ${project.title}`}
>
<div class={`relative ${idx === 0 ? 'h-72 sm:h-80' : 'h-56'} overflow-hidden bg-secondary-200`}>
<img
src={project.thumbnail}
alt={`${project.title} project screenshot`}
width={idx === 0 ? 800 : 600}
height={idx === 0 ? 500 : 400}
class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-110"
loading={idx === 0 ? 'eager' : 'lazy'}
decoding={idx === 0 ? 'sync' : 'async'}
fetchpriority={idx === 0 ? 'high' : 'auto'}
/>
<div class="absolute inset-0 bg-gradient-to-t from-secondary-900/90 via-secondary-900/30 to-transparent"></div>
<!-- Category Badge -->
<span class={`absolute right-4 top-4 rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white ${
project.category === 'web' ? 'bg-primary' :
project.category === 'mobile' ? 'bg-accent' :
project.category === 'government' ? 'bg-emerald-500' :
project.category === 'enterprise' ? 'bg-purple-500' :
'bg-secondary-500'
}`}>
{project.category}
</span>
<!-- Year badge -->
<span class="absolute left-4 top-4 rounded-full bg-black/40 backdrop-blur-sm px-3 py-1 text-xs font-medium text-white/80">
{project.year}
</span>
<!-- Content overlay -->
<div class="absolute bottom-0 left-0 p-6">
<h3 class="text-xl font-bold text-white sm:text-2xl">{project.title}</h3>
<p class="mt-1.5 text-sm font-semibold leading-snug text-primary-300">{project.outcome}</p>
<p class="mt-1 text-sm text-secondary-300 line-clamp-2">{project.description}</p>
<!-- Stats pills on featured card -->
<div class="mt-3 flex flex-wrap gap-2">
{project.stats.slice(0, 2).map(stat => (
<span class="rounded-full bg-white/15 backdrop-blur-sm px-2.5 py-1 text-xs font-semibold text-white">
{stat.value} {stat.label}
</span>
))}
</div>
</div>
<!-- Hover overlay with "view" CTA -->
<div class="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<span class="inline-flex items-center gap-2 rounded-full bg-white px-5 py-2.5 text-sm font-semibold text-secondary-900 shadow-lg">
View Case Study
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</span>
</div>
</div>
</article>
))}
</div>
</div>
</section>
<!-- Filter & Grid Section -->
<section class="bg-secondary-50 dark:bg-secondary-900 py-16 lg:py-24">
<div class="container-wrapper">
<!-- Search + Filter Controls -->
<div class="mb-10 space-y-6 reveal-on-scroll">
<!-- Search -->
<div class="relative mx-auto max-w-xl">
<label for="project-search" class="sr-only">Search projects</label>
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
id="project-search"
type="search"
placeholder="Search by project name or technology..."
class="w-full rounded-xl border border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 py-3 pl-12 pr-4 text-secondary-800 dark:text-secondary-100 shadow-sm placeholder-secondary-400 dark:placeholder-secondary-500 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-400/20 transition-colors"
autocomplete="off"
/>
<div id="search-clear" class="absolute inset-y-0 right-0 hidden items-center pr-4">
<button class="text-secondary-400 hover:text-secondary-600 focus:outline-none" aria-label="Clear search">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Category Filter Tabs
Mobile (<sm): a single horizontally-scrollable row so the five
chips never wrap into a tall stack that pushes the project grid
below the fold. ≥sm: centered wrapping row as before. -->
<div class="filter-scroll flex flex-nowrap gap-3 overflow-x-auto pb-1 snap-x sm:flex-wrap sm:justify-center sm:overflow-visible sm:pb-0" role="tablist" aria-label="Filter projects by category">
<button
data-filter="all"
class="filter-btn active rounded-full bg-primary px-6 py-2.5 font-semibold text-white transition-all duration-300 hover:shadow-lg hover:shadow-primary/25 focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-2"
role="tab"
aria-selected="true"
aria-controls="projects-grid"
>
All Projects
</button>
<button
data-filter="web"
class="filter-btn rounded-full bg-white dark:bg-secondary-800 px-6 py-2.5 font-semibold text-secondary-600 dark:text-secondary-400 shadow-sm transition-all duration-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-2"
role="tab"
aria-selected="false"
aria-controls="projects-grid"
>
Web Development
</button>
<button
data-filter="government"
class="filter-btn rounded-full bg-white dark:bg-secondary-800 px-6 py-2.5 font-semibold text-secondary-600 dark:text-secondary-400 shadow-sm transition-all duration-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-2"
role="tab"
aria-selected="false"
aria-controls="projects-grid"
>
Government
</button>
<button
data-filter="enterprise"
class="filter-btn rounded-full bg-white dark:bg-secondary-800 px-6 py-2.5 font-semibold text-secondary-600 dark:text-secondary-400 shadow-sm transition-all duration-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-2"
role="tab"
aria-selected="false"
aria-controls="projects-grid"
>
Enterprise
</button>
<button
data-filter="mobile"
class="filter-btn rounded-full bg-white dark:bg-secondary-800 px-6 py-2.5 font-semibold text-secondary-600 dark:text-secondary-400 shadow-sm transition-all duration-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-2"
role="tab"
aria-selected="false"
aria-controls="projects-grid"
>
Mobile Apps
</button>
</div>
<!-- Technology Filter Pills (collapsible) -->
<div class="text-center">
<button
id="tech-filter-toggle"
class="inline-flex items-center gap-2 text-sm font-medium text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors focus:outline-none"
aria-expanded="false"
aria-controls="tech-filter-panel"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2a1 1 0 01-.293.707l-6.414 6.414A1 1 0 0014 13.828V19a1 1 0 01-1.447.894l-4-2A1 1 0 018 17v-3.172a1 1 0 00-.293-.707L1.293 6.707A1 1 0 011 6V4z" />
</svg>
Filter by Technology
<svg id="tech-toggle-icon" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 transition-transform duration-200" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<div
id="tech-filter-panel"
class="hidden mt-4 flex flex-wrap justify-center gap-2"
aria-hidden="true"
>
<button
data-tech="all"
class="tech-btn active rounded-full bg-secondary-900 px-4 py-1.5 text-xs font-semibold text-white transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-secondary-400"
>
All Technologies
</button>
{allTechs.map((tech) => (
<button
data-tech={tech}
class="tech-btn rounded-full bg-white dark:bg-secondary-800 px-4 py-1.5 text-xs font-semibold text-secondary-600 dark:text-secondary-400 shadow-sm transition-all duration-200 hover:bg-secondary-100 dark:hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-secondary-400"
>
{tech}
</button>
))}
</div>
</div>
<!-- Active filter indicator + count -->
<div class="flex items-center justify-between text-sm text-secondary-500 dark:text-secondary-400">
<span id="results-count" aria-live="polite" aria-atomic="true">
Showing <strong id="count-num" class="text-secondary-800 dark:text-secondary-100">{projects.length}</strong> projects
</span>
<button
id="clear-filters"
class="hidden text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium transition-colors focus:outline-none"
>
Clear all filters
</button>
</div>
</div>
<!-- Projects Grid -->
<div
id="projects-grid"
class="grid gap-8 sm:grid-cols-2 lg:grid-cols-3"
role="tabpanel"
aria-label="Filtered project results"
>
{projects.map((project) => (
<article
class="project-card group relative overflow-hidden rounded-2xl bg-white dark:bg-secondary-800 shadow-md transition-all duration-500 hover:-translate-y-2 hover:shadow-2xl cursor-pointer reveal-on-scroll"
data-category={project.category}
data-project-id={project.id}
data-tech={project.techStack.join(',')}
data-title={project.title.toLowerCase()}
tabindex="0"
role="button"
aria-label={`View case study for ${project.title}`}
>
<!-- Thumbnail -->
<div class="relative h-56 overflow-hidden bg-secondary-200">
<img
src={project.thumbnail}
alt={`${project.title} project screenshot`}
width="600"
height="400"
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-110"
loading="lazy"
decoding="async"
fetchpriority="low"
/>
<div class="absolute inset-0 bg-gradient-to-t from-secondary-900/70 via-transparent to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"></div>
<!-- Category Badge -->
<span class={`absolute right-4 top-4 rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white ${
project.category === 'web' ? 'bg-primary' :
project.category === 'mobile' ? 'bg-accent' :
project.category === 'government' ? 'bg-emerald-500' :
project.category === 'enterprise' ? 'bg-purple-500' :
'bg-secondary-500'
}`}>
{project.category}
</span>
<!-- Year -->
<span class="absolute left-4 top-4 rounded-full bg-black/40 backdrop-blur-sm px-2.5 py-1 text-xs font-medium text-white/80">
{project.year}
</span>
<!-- Gallery count badge (shows if more than 1 image) -->
{project.gallery.length > 1 && (
<span class="absolute bottom-3 right-3 inline-flex items-center gap-1 rounded-full bg-black/50 backdrop-blur-sm px-2 py-1 text-xs text-white/80 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{project.gallery.length}
</span>
)}
<!-- Hover CTA overlay -->
<div class="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<span class="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-sm font-semibold text-secondary-900 shadow-lg">
View Case Study
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</span>
</div>
</div>
<!-- Content -->
<div class="p-6">
<h3 class="text-xl font-bold text-secondary-900 dark:text-secondary-100 transition-colors group-hover:text-primary dark:group-hover:text-primary-400">
{project.title}
</h3>
<!-- Outcome headline -->
<p class="mt-2 text-sm font-semibold leading-snug text-primary dark:text-primary-400">
{project.outcome}
</p>
<p class="mt-1.5 line-clamp-2 text-sm text-secondary-600 dark:text-secondary-400">
{project.description}
</p>
<!-- Key metric pill -->
<div class="mt-3 inline-flex items-center gap-1.5 rounded-full bg-secondary-100 dark:bg-secondary-700 px-3 py-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
<span class="text-xs font-semibold text-secondary-700 dark:text-secondary-300">{project.stats[0].value} {project.stats[0].label}</span>
</div>
<!-- Tech Stack Tags -->
<div class="mt-4 flex flex-wrap gap-1.5">
{project.techStack.map((tech) => (
<span class="rounded-md bg-secondary-100 dark:bg-secondary-700 px-2.5 py-1 text-xs font-medium text-secondary-700 dark:text-secondary-300 hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 transition-colors cursor-pointer tech-tag" data-tech-tag={tech}>
{tech}
</span>
))}
</div>
<!-- Footer row: client + arrow -->
<div class="mt-5 flex items-center justify-between border-t border-secondary-100 dark:border-secondary-700 pt-4">
<span class="text-xs text-secondary-500 dark:text-secondary-400">{project.client}</span>
<span class="inline-flex items-center gap-1 text-xs font-semibold text-primary transition-all group-hover:gap-2">
Details
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</span>
</div>
</div>
</article>
))}
</div>
<!-- No Results Message -->
<div id="no-results" class="hidden py-20 text-center">
<div class="mx-auto h-20 w-20 rounded-2xl bg-secondary-100 dark:bg-secondary-800 flex items-center justify-center mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-secondary-300 dark:text-secondary-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<p class="text-lg font-semibold text-secondary-700 dark:text-secondary-300">No projects match your filters</p>
<p class="mt-2 text-secondary-500 dark:text-secondary-400">Try adjusting your search or filter criteria.</p>
<button
id="reset-filters-btn"
class="mt-6 inline-flex items-center gap-2 rounded-lg bg-primary px-6 py-2.5 font-semibold text-white transition-all hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-2"
>
Reset Filters
</button>
</div>
</div>
</section>
<!-- Case Study Modal / Lightbox -->
<div
id="case-study-modal"
class="fixed inset-0 z-50 hidden items-center justify-center overflow-y-auto bg-secondary-900/85 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div class="modal-content relative w-full max-w-5xl scale-95 transform rounded-2xl bg-white dark:bg-secondary-800 opacity-0 shadow-2xl transition-all duration-300 my-4">
<!-- Close Button -->
<button
id="close-modal-btn"
class="absolute right-4 top-4 z-20 rounded-full bg-white/90 backdrop-blur-sm p-2.5 text-secondary-500 shadow-md transition-colors hover:bg-white hover:text-secondary-700 focus:outline-none focus:ring-2 focus:ring-primary-400"
aria-label="Close case study"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Modal Content (injected by JS) -->
<div id="modal-body" class="overflow-hidden rounded-2xl">
</div>
</div>
</div>
<!-- CTA Section -->
<section class="bg-gradient-to-r from-primary-700 to-primary py-16 lg:py-20 relative overflow-hidden">
<div class="absolute inset-0 pointer-events-none" aria-hidden="true">
<div class="absolute top-0 left-1/4 w-64 h-64 bg-white/5 rounded-full blur-3xl"></div>
<div class="absolute bottom-0 right-1/4 w-80 h-80 bg-white/5 rounded-full blur-3xl"></div>
</div>
<div class="container-wrapper relative reveal-on-scroll">
<div class="mx-auto max-w-3xl text-center">
<h2 class="text-3xl font-bold text-white sm:text-4xl">Ready to Build Something Great?</h2>
<p class="mt-4 text-lg text-primary-100 leading-relaxed">
Every project in our portfolio started with a conversation. Let's talk about yours.
</p>
<div class="mt-8 flex flex-wrap justify-center gap-4">
<a
href="/contact"
class="inline-flex items-center justify-center rounded-xl bg-white px-8 py-3.5 font-semibold text-primary transition-all hover:bg-primary-50 hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-primary"
>
Get my free proposal
</a>
<a
href="/services"
class="inline-flex items-center justify-center rounded-xl border-2 border-white/40 bg-transparent px-8 py-3.5 font-semibold text-white transition-all hover:bg-white/10 hover:border-white/60 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-primary"
>
View Services
</a>
</div>
</div>
</div>
</section>
</BaseLayout>
<script define:vars={{ projects }}>
window.portfolioProjects = projects;
</script>
<script>
import { trackPortfolioFilter, trackCaseStudyView, trackEvent } from '../utils/analytics';
document.addEventListener('DOMContentLoaded', () => {
// ─── State ───────────────────────────────────────────────────────────────
let activeCategory = 'all';
let activeTech = 'all';
let searchQuery = '';
// ─── Elements ────────────────────────────────────────────────────────────
const filterBtns = document.querySelectorAll<HTMLButtonElement>('.filter-btn');
const techBtns = document.querySelectorAll<HTMLButtonElement>('.tech-btn');
const projectCards = document.querySelectorAll<HTMLElement>('.project-card');
const featuredCards = document.querySelectorAll<HTMLElement>('.featured-card');
const noResults = document.getElementById('no-results');
const countNum = document.getElementById('count-num');
const clearFilters = document.getElementById('clear-filters');
const resetFiltersBtn = document.getElementById('reset-filters-btn');
const searchInput = document.getElementById('project-search') as HTMLInputElement;
const searchClear = document.getElementById('search-clear');
const techToggle = document.getElementById('tech-filter-toggle');
const techPanel = document.getElementById('tech-filter-panel');
const techToggleIcon = document.getElementById('tech-toggle-icon');
const modal = document.getElementById('case-study-modal');
const modalBody = document.getElementById('modal-body');
const closeModalBtn = document.getElementById('close-modal-btn');
// ─── Tech filter toggle ───────────────────────────────────────────────────
techToggle?.addEventListener('click', () => {
const isExpanded = techToggle.getAttribute('aria-expanded') === 'true';
techToggle.setAttribute('aria-expanded', String(!isExpanded));
techPanel?.setAttribute('aria-hidden', String(isExpanded));
if (isExpanded) {
techPanel?.classList.add('hidden');
techPanel?.classList.remove('flex');
techToggleIcon?.classList.remove('rotate-180');
} else {
techPanel?.classList.remove('hidden');
techPanel?.classList.add('flex');
techToggleIcon?.classList.add('rotate-180');
}
});
// ─── Filtering logic ──────────────────────────────────────────────────────
const applyFilters = () => {
let visibleCount = 0;
projectCards.forEach((card, index) => {
const category = card.dataset.category ?? '';
const techData = card.dataset.tech ?? '';
const title = card.dataset.title ?? '';
const techs = techData.split(',');
const matchesCategory = activeCategory === 'all' || category === activeCategory;
const matchesTech = activeTech === 'all' || techs.includes(activeTech);
const matchesSearch = searchQuery === '' ||
title.includes(searchQuery.toLowerCase()) ||
techData.toLowerCase().includes(searchQuery.toLowerCase());
const shouldShow = matchesCategory && matchesTech && matchesSearch;
if (shouldShow) {
card.classList.remove('hidden');
card.style.opacity = '0';
card.style.transform = 'translateY(16px)';
const delay = visibleCount * 40;
setTimeout(() => {
card.style.opacity = '1';
card.style.transform = 'translateY(0)';
}, delay);
visibleCount++;
} else {
card.style.opacity = '0';
card.style.transform = 'translateY(16px)';
setTimeout(() => card.classList.add('hidden'), 280);
}
});
// Update count
setTimeout(() => {
if (countNum) countNum.textContent = String(visibleCount);
// Show/hide no results
if (visibleCount === 0) {
noResults?.classList.remove('hidden');
} else {
noResults?.classList.add('hidden');
}
// Show/hide clear button
const filtersActive = activeCategory !== 'all' || activeTech !== 'all' || searchQuery !== '';
clearFilters?.classList.toggle('hidden', !filtersActive);
}, 300);
};
const resetAllFilters = () => {
activeCategory = 'all';
activeTech = 'all';
searchQuery = '';
if (searchInput) searchInput.value = '';
searchClear?.classList.add('hidden');
searchClear?.classList.remove('flex');
filterBtns.forEach(b => {
b.classList.remove('active', 'bg-primary', 'text-white');
b.classList.add('bg-white', 'text-secondary-600', 'shadow-sm');
b.setAttribute('aria-selected', 'false');
if (b.dataset.filter === 'all') {
b.classList.add('bg-primary', 'text-white');
b.setAttribute('aria-selected', 'true');
}
});
techBtns.forEach(b => {
b.classList.remove('active', 'bg-secondary-900', 'text-white');
b.classList.add('bg-white', 'text-secondary-600', 'shadow-sm');
if (b.dataset.tech === 'all') {
b.classList.add('bg-secondary-900', 'text-white');
}
});
applyFilters();
};
// ─── Category filter ─────────────────────────────────────────────────────
filterBtns.forEach(btn => {
btn.addEventListener('click', () => {
const filter = btn.dataset.filter ?? 'all';
activeCategory = filter;
if (filter) trackPortfolioFilter(filter);
filterBtns.forEach(b => {
b.classList.remove('active', 'bg-primary', 'text-white', 'hover:shadow-lg', 'hover:shadow-primary/25');
b.classList.add('bg-white', 'text-secondary-600', 'shadow-sm', 'hover:bg-secondary-100', 'hover:shadow-md');
b.setAttribute('aria-selected', 'false');
});
btn.classList.add('active', 'bg-primary', 'text-white', 'hover:shadow-lg', 'hover:shadow-primary/25');
btn.classList.remove('bg-white', 'text-secondary-600', 'shadow-sm', 'hover:bg-secondary-100', 'hover:shadow-md');
btn.setAttribute('aria-selected', 'true');
applyFilters();
});
});
// ─── Technology filter ────────────────────────────────────────────────────
techBtns.forEach(btn => {
btn.addEventListener('click', () => {
const tech = btn.dataset.tech ?? 'all';
activeTech = tech;
trackEvent('portfolio_tech_filter', { technology: tech });
techBtns.forEach(b => {
b.classList.remove('active', 'bg-secondary-900', 'text-white');
b.classList.add('bg-white', 'text-secondary-600', 'shadow-sm');
});
btn.classList.add('active', 'bg-secondary-900', 'text-white');
btn.classList.remove('bg-white', 'text-secondary-600', 'shadow-sm');
applyFilters();
});
});
// ─── Tech tags on cards (click to filter) ────────────────────────────────
document.querySelectorAll<HTMLElement>('.tech-tag').forEach(tag => {
tag.addEventListener('click', (e) => {
e.stopPropagation(); // prevent card modal opening
const tech = tag.dataset.techTag ?? '';
if (!tech) return;
activeTech = tech;
// Open tech panel if hidden
if (techPanel?.classList.contains('hidden')) {
techPanel.classList.remove('hidden');
techPanel.classList.add('flex');
techToggle?.setAttribute('aria-expanded', 'true');
techPanel.setAttribute('aria-hidden', 'false');
techToggleIcon?.classList.add('rotate-180');
}
// Update active tech btn
techBtns.forEach(b => {
b.classList.remove('active', 'bg-secondary-900', 'text-white');
b.classList.add('bg-white', 'text-secondary-600', 'shadow-sm');
if (b.dataset.tech === tech) {
b.classList.add('active', 'bg-secondary-900', 'text-white');
b.classList.remove('bg-white', 'text-secondary-600', 'shadow-sm');
}
});
applyFilters();
});
});
// ─── Search input ─────────────────────────────────────────────────────────
let searchTimeout: ReturnType<typeof setTimeout>;
searchInput?.addEventListener('input', () => {
searchQuery = searchInput.value.trim();
if (searchQuery) {
searchClear?.classList.remove('hidden');
searchClear?.classList.add('flex');
} else {
searchClear?.classList.add('hidden');
searchClear?.classList.remove('flex');
}
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
applyFilters();
if (searchQuery) trackEvent('portfolio_search', { query: searchQuery });
}, 300);
});
searchClear?.querySelector('button')?.addEventListener('click', () => {
if (searchInput) searchInput.value = '';
searchQuery = '';
searchClear.classList.add('hidden');
searchClear.classList.remove('flex');
applyFilters();
});
// ─── Clear / Reset buttons ────────────────────────────────────────────────
clearFilters?.addEventListener('click', resetAllFilters);
resetFiltersBtn?.addEventListener('click', resetAllFilters);
// ─── Modal functionality ──────────────────────────────────────────────────
let currentGalleryIdx = 0;
const getCategoryColor = (cat: string) => {
if (cat === 'web') return 'bg-primary';
if (cat === 'mobile') return 'bg-accent';
return 'bg-emerald-500';
};
const getCategoryLabel = (cat: string) => cat === 'ai' ? 'AI/ML' : cat;
const openModal = (projectId: string) => {
const project = (window as any).portfolioProjects.find((p: any) => p.id === projectId);
if (!project || !modal || !modalBody) return;
trackCaseStudyView(project.id, project.title);
currentGalleryIdx = 0;
const catColor = getCategoryColor(project.category);
const catLabel = getCategoryLabel(project.category);
const galleryDotsHtml = project.gallery.length > 1
? `<div class="flex items-center justify-center gap-2 py-3 bg-secondary-900/90" id="gallery-dots" aria-label="Image ${currentGalleryIdx + 1} of ${project.gallery.length}">
${project.gallery.map((_: string, i: number) => `
<button
class="gallery-dot w-2 h-2 rounded-full transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-white ${i === 0 ? 'bg-white scale-125' : 'bg-white/40 hover:bg-white/70'}"
data-index="${i}"
aria-label="View image ${i + 1}"
></button>
`).join('')}
</div>`
: '';
const galleryNavHtml = project.gallery.length > 1
? `<button id="gallery-prev" class="absolute left-3 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-black/50 backdrop-blur-sm text-white flex items-center justify-center hover:bg-black/70 transition-colors focus:outline-none focus:ring-2 focus:ring-white" aria-label="Previous image">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
</button>
<button id="gallery-next" class="absolute right-3 top-1/2 -translate-y-1/2 z-10 w-10 h-10 rounded-full bg-black/50 backdrop-blur-sm text-white flex items-center justify-center hover:bg-black/70 transition-colors focus:outline-none focus:ring-2 focus:ring-white" aria-label="Next image">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
</button>`
: '';
modalBody.innerHTML = `
<!-- Gallery -->
<div class="relative bg-secondary-900">
<div class="relative h-64 sm:h-80 lg:h-96 overflow-hidden" id="gallery-viewport">
<img
id="gallery-img"
src="${project.gallery[0]}"
alt="${project.title} screenshot 1"
class="h-full w-full object-cover transition-opacity duration-300"
loading="eager"
decoding="async"
/>
<div class="absolute inset-0 bg-gradient-to-t from-secondary-900/80 via-transparent to-transparent pointer-events-none"></div>
<!-- Category + title overlay -->
<div class="absolute bottom-0 left-0 p-6 sm:p-8">
<span class="inline-block rounded-full ${catColor} px-3 py-1 text-xs font-semibold uppercase tracking-wide text-white mb-3">
${catLabel}
</span>
<h2 id="modal-title" class="text-2xl font-bold text-white sm:text-3xl">${project.title}</h2>
<p class="text-secondary-300 text-sm mt-1">${project.client} · ${project.duration} · ${project.year}</p>
</div>
${galleryNavHtml}
</div>
${galleryDotsHtml}
</div>
<!-- Body -->
<div class="p-6 sm:p-8 lg:p-10">
<!-- Stats row -->
<div class="grid grid-cols-3 gap-4 mb-8 p-5 bg-secondary-50 dark:bg-secondary-700/50 rounded-2xl">
${project.stats.map((stat: any) => `
<div class="text-center">
<p class="text-2xl sm:text-3xl font-bold text-secondary-900 dark:text-secondary-100">${stat.value}</p>
<p class="text-xs text-secondary-500 dark:text-secondary-400 mt-1">${stat.label}</p>
</div>
`).join('')}
</div>
<!-- Two column layout: overview + challenge/solution -->
<div class="grid gap-8 lg:grid-cols-2 mb-8">
<div>
<h3 class="text-sm font-semibold uppercase tracking-wide text-secondary-400 dark:text-secondary-500 mb-3">Project Overview</h3>
<p class="text-secondary-700 dark:text-secondary-300 leading-relaxed text-sm">${project.fullDescription}</p>
</div>
<div class="space-y-5">
<div>
<h3 class="text-sm font-semibold uppercase tracking-wide text-secondary-400 dark:text-secondary-500 mb-2">The Challenge</h3>
<p class="text-secondary-700 dark:text-secondary-300 text-sm leading-relaxed">${project.challenge}</p>
</div>
<div>
<h3 class="text-sm font-semibold uppercase tracking-wide text-secondary-400 dark:text-secondary-500 mb-2">Our Solution</h3>
<p class="text-secondary-700 dark:text-secondary-300 text-sm leading-relaxed">${project.solution}</p>
</div>
</div>
</div>
<!-- Bottom row: tech stack + results -->
<div class="grid gap-6 sm:grid-cols-2 pt-6 border-t border-secondary-100 dark:border-secondary-700">
<div>
<h3 class="text-sm font-semibold uppercase tracking-wide text-secondary-400 dark:text-secondary-500 mb-3">Tech Stack</h3>
<div class="flex flex-wrap gap-2">
${project.techStack.map((tech: string) => `<span class="rounded-lg bg-secondary-100 dark:bg-secondary-700 px-3 py-1.5 text-xs font-semibold text-secondary-700 dark:text-secondary-300">${tech}</span>`).join('')}
</div>
</div>
<div>
<h3 class="text-sm font-semibold uppercase tracking-wide text-secondary-400 dark:text-secondary-500 mb-3">Key Results</h3>
<ul class="space-y-2">
${project.results.map((result: string) => `
<li class="flex items-center gap-2 text-sm text-secondary-700 dark:text-secondary-300">
<svg class="h-4 w-4 flex-shrink-0 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
</svg>
${result}
</li>
`).join('')}
</ul>
</div>
</div>
<!-- CTA -->
<div class="mt-8 flex flex-col sm:flex-row gap-3 justify-end">
<button id="modal-close-cta" class="order-2 sm:order-1 inline-flex items-center justify-center px-5 py-2.5 rounded-lg border border-secondary-200 dark:border-secondary-600 text-secondary-600 dark:text-secondary-400 text-sm font-medium hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors focus:outline-none focus:ring-2 focus:ring-secondary-400">
Back to Portfolio
</button>
<a href="/contact" class="order-1 sm:order-2 inline-flex items-center justify-center px-6 py-2.5 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary-600 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-400">
Start a Similar Project
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</a>
</div>
</div>
`;
// Show modal
modal.classList.remove('hidden');
modal.classList.add('flex');
document.body.style.overflow = 'hidden';
requestAnimationFrame(() => {
const mc = modal.querySelector<HTMLElement>('.modal-content');
mc?.classList.remove('scale-95', 'opacity-0');
mc?.classList.add('scale-100', 'opacity-100');
});
// Focus close button
closeModalBtn?.focus();
// Wire gallery nav
if (project.gallery.length > 1) {
const galleryImg = document.getElementById('gallery-img') as HTMLImageElement;
const dots = document.querySelectorAll<HTMLButtonElement>('.gallery-dot');
const setGalleryImage = (idx: number) => {
if (!galleryImg) return;
currentGalleryIdx = ((idx % project.gallery.length) + project.gallery.length) % project.gallery.length;
galleryImg.style.opacity = '0';
setTimeout(() => {
galleryImg.src = project.gallery[currentGalleryIdx];
galleryImg.alt = `${project.title} screenshot ${currentGalleryIdx + 1}`;
galleryImg.style.opacity = '1';
}, 150);
dots.forEach((d, i) => {
d.classList.toggle('bg-white', i === currentGalleryIdx);
d.classList.toggle('scale-125', i === currentGalleryIdx);
d.classList.toggle('bg-white/40', i !== currentGalleryIdx);
d.classList.toggle('scale-100', i !== currentGalleryIdx);
});
const dotsContainer = document.getElementById('gallery-dots');
if (dotsContainer) dotsContainer.setAttribute('aria-label', `Image ${currentGalleryIdx + 1} of ${project.gallery.length}`);
};
document.getElementById('gallery-prev')?.addEventListener('click', () => setGalleryImage(currentGalleryIdx - 1));
document.getElementById('gallery-next')?.addEventListener('click', () => setGalleryImage(currentGalleryIdx + 1));
dots.forEach((dot, i) => dot.addEventListener('click', () => setGalleryImage(i)));
// Keyboard gallery nav
modal.addEventListener('keydown', galleryKeyHandler = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') setGalleryImage(currentGalleryIdx - 1);
if (e.key === 'ArrowRight') setGalleryImage(currentGalleryIdx + 1);
});
}
// Wire internal close CTA
document.getElementById('modal-close-cta')?.addEventListener('click', closeModal);
};
let galleryKeyHandler: ((e: KeyboardEvent) => void) | null = null;
const closeModal = () => {
if (!modal) return;
const mc = modal.querySelector<HTMLElement>('.modal-content');
mc?.classList.remove('scale-100', 'opacity-100');
mc?.classList.add('scale-95', 'opacity-0');
if (galleryKeyHandler) {
modal.removeEventListener('keydown', galleryKeyHandler);
galleryKeyHandler = null;
}
setTimeout(() => {
modal.classList.add('hidden');
modal.classList.remove('flex');
document.body.style.overflow = '';
}, 300);
};
// Wire all project card clicks (grid cards + featured cards)
const allClickableCards = document.querySelectorAll<HTMLElement>('.project-card, .featured-card');
allClickableCards.forEach(card => {
const handleCardActivate = () => {
const projectId = card.dataset.projectId;
if (projectId) openModal(projectId);
};
card.addEventListener('click', handleCardActivate);
card.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleCardActivate();
}
});
});
closeModalBtn?.addEventListener('click', closeModal);
modal?.addEventListener('click', (e) => {
if (e.target === modal) closeModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !modal?.classList.contains('hidden')) {
closeModal();
}
});
// ─── Card transition styles ───────────────────────────────────────────────
projectCards.forEach(card => {
card.style.transition = 'opacity 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease';
});
// ─── Scroll-reveal animations ─────────────────────────────────────────────
const revealEls = document.querySelectorAll('.reveal-on-scroll');
if (revealEls.length) {
const revealObserver = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('revealed');
revealObserver.unobserve(entry.target);
}
});
},
{ threshold: 0.1, rootMargin: '0px 0px -40px 0px' }
);
revealEls.forEach(el => revealObserver.observe(el));
}
});
</script>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Scroll-reveal */
.reveal-on-scroll {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.reveal-on-scroll.revealed {
opacity: 1;
transform: translateY(0);
}
.reveal-delay-1 { transition-delay: 0.1s; }
.reveal-delay-2 { transition-delay: 0.2s; }
.reveal-delay-3 { transition-delay: 0.3s; }
@media (prefers-reduced-motion: reduce) {
.reveal-on-scroll {
opacity: 1;
transform: none;
transition: none;
}
.project-card,
.featured-card {
transition: box-shadow 0.2s ease !important;
}
}
/* Gallery image crossfade */
#gallery-img {
transition: opacity 0.15s ease;
}
/* Featured card grid on larger screens */
@media (min-width: 1024px) {
.featured-cards-grid {
grid-template-columns: 2fr 1fr;
}
}
/* Modal content animation */
.modal-content {
transition: opacity 0.3s ease, transform 0.3s ease;
}
/* Tech filter panel flex when open */
#tech-filter-panel:not(.hidden) {
display: flex;
flex-wrap: wrap;
}
/* Horizontally-scrollable category filter row on mobile.
Chips keep their intrinsic width (no shrinking) and snap as the user
swipes; the scrollbar is hidden for a clean native-app feel. At >=sm the
row wraps normally so the scroll affordance is mobile-only. */
.filter-scroll {
-webkit-overflow-scrolling: touch;
scroll-snap-type: x proximity;
}
.filter-scroll > .filter-btn {
flex: 0 0 auto;
white-space: nowrap;
scroll-snap-align: start;
}
/* Hide the scrollbar (still scrollable) only while in the mobile scroll mode */
@media (max-width: 639px) {
.filter-scroll {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge legacy */
}
.filter-scroll::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
}
</style>