Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf649b28ae | |||
| 546d020d5a | |||
| 41ba7a7546 | |||
| b0a6f0dbb7 | |||
| 8b60177ad1 | |||
| e800be26cd | |||
| 9d8b850abc | |||
| fbcc591b0e | |||
| bc899d6e44 | |||
| f69459c79e | |||
| c696694318 | |||
| 36b2c55b8d | |||
| 0f5ae59296 | |||
| 352af2b1b6 | |||
| 44a13a952f | |||
| abf5473f6e | |||
| 00b122afac | |||
| 03f62ec21d | |||
| 3782a4c02d | |||
| ef348208d8 | |||
| 0d569ac586 |
@@ -1,48 +1,108 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
||||||
import NavbarStyleCentered from "@/components/navbar/NavbarStyleCentered/NavbarStyleCentered";
|
import NavbarStyleCentered from "@/components/navbar/NavbarStyleCentered/NavbarStyleCentered";
|
||||||
import { useState } from "react";
|
import FooterBase from "@/components/sections/footer/FooterBase";
|
||||||
import { BarChart3, Users, Briefcase, FileText, TrendingUp, LogOut } from "lucide-react";
|
import { Users, Briefcase, BarChart3, ChevronDown, Plus, Trash2, Edit2 } from "lucide-react";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: "Search Jobs", id: "" },
|
{ name: "Dashboard", id: "/admin" },
|
||||||
{ name: "Post a Job", id: "" },
|
{ name: "Jobs", id: "/admin" },
|
||||||
{ name: "Admin", id: "/admin" },
|
{ name: "Users", id: "/admin" },
|
||||||
{ name: "Browse", id: "" },
|
{ name: "Analytics", id: "/admin" },
|
||||||
{ name: "Contact", id: "" },
|
{ name: "Home", id: "/" },
|
||||||
];
|
];
|
||||||
|
|
||||||
type TabType = "jobs" | "applications" | "users" | "analytics";
|
const footerColumns = [
|
||||||
|
{
|
||||||
|
title: "Product", items: [
|
||||||
|
{ label: "Search Jobs", href: "/search" },
|
||||||
|
{ label: "Post a Job", href: "/post-job" },
|
||||||
|
{ label: "Browse by Province", href: "#provinces" },
|
||||||
|
{ label: "For Employers", href: "#" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Company", items: [
|
||||||
|
{ label: "About Jobee", href: "#about" },
|
||||||
|
{ label: "Careers", href: "#" },
|
||||||
|
{ label: "Contact Us", href: "#contact" },
|
||||||
|
{ label: "Blog", href: "#" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Resources", items: [
|
||||||
|
{ label: "Privacy Policy", href: "#" },
|
||||||
|
{ label: "Terms of Service", href: "#" },
|
||||||
|
{ label: "FAQ", href: "#" },
|
||||||
|
{ label: "Support", href: "#" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Job {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
company: string;
|
||||||
|
location: string;
|
||||||
|
status: "active" | "closed";
|
||||||
|
applications: number;
|
||||||
|
postedDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: "job_seeker" | "employer";
|
||||||
|
joinDate: string;
|
||||||
|
status: "active" | "inactive";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnalyticsData {
|
||||||
|
totalJobs: number;
|
||||||
|
totalUsers: number;
|
||||||
|
activeApplications: number;
|
||||||
|
successfulPlacements: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminDashboard() {
|
export default function AdminDashboard() {
|
||||||
const [activeTab, setActiveTab] = useState<TabType>("jobs");
|
const [activeTab, setActiveTab] = useState<"dashboard" | "jobs" | "users">("dashboard");
|
||||||
const [jobs] = useState([
|
const [jobs, setJobs] = useState<Job[]>([
|
||||||
{ id: 1, title: "Senior Developer", company: "Tech Corp", status: "Active", applications: 12, posted: "2 days ago" },
|
{
|
||||||
{ id: 2, title: "Product Manager", company: "Startup Inc", status: "Active", applications: 8, posted: "5 days ago" },
|
id: "1", title: "Senior React Developer", company: "TechCorp", location: "Amsterdam", status: "active", applications: 24,
|
||||||
{ id: 3, title: "Designer", company: "Creative Agency", status: "Inactive", applications: 5, posted: "10 days ago" },
|
postedDate: "2025-01-10"},
|
||||||
|
{
|
||||||
|
id: "2", title: "UX Designer", company: "DesignStudio", location: "Rotterdam", status: "active", applications: 18,
|
||||||
|
postedDate: "2025-01-08"},
|
||||||
|
{
|
||||||
|
id: "3", title: "Backend Engineer", company: "DataSystems", location: "Utrecht", status: "closed", applications: 42,
|
||||||
|
postedDate: "2024-12-20"},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [applications] = useState([
|
const [users, setUsers] = useState<User[]>([
|
||||||
{ id: 1, candidate: "John Smith", position: "Senior Developer", status: "Under Review", appliedDate: "2025-01-20" },
|
{
|
||||||
{ id: 2, candidate: "Sarah Johnson", position: "Product Manager", status: "Interview", appliedDate: "2025-01-19" },
|
id: "1", name: "Alice Johnson", email: "alice@example.com", role: "job_seeker", joinDate: "2024-11-15", status: "active"},
|
||||||
{ id: 3, candidate: "Mike Davis", position: "Senior Developer", status: "Rejected", appliedDate: "2025-01-18" },
|
{
|
||||||
|
id: "2", name: "Bob Smith", email: "bob@example.com", role: "employer", joinDate: "2024-10-20", status: "active"},
|
||||||
|
{
|
||||||
|
id: "3", name: "Carol White", email: "carol@example.com", role: "job_seeker", joinDate: "2024-09-05", status: "inactive"},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [users] = useState([
|
const [analytics] = useState<AnalyticsData>({
|
||||||
{ id: 1, name: "Alice Chen", email: "alice@example.com", role: "Job Seeker", joined: "2025-01-10", status: "Active" },
|
totalJobs: 156,
|
||||||
{ id: 2, name: "Bob Wilson", email: "bob@example.com", role: "Employer", joined: "2025-01-05", status: "Active" },
|
totalUsers: 3245,
|
||||||
{ id: 3, name: "Carol White", email: "carol@example.com", role: "Job Seeker", joined: "2024-12-20", status: "Inactive" },
|
activeApplications: 892,
|
||||||
]);
|
successfulPlacements: 142,
|
||||||
|
});
|
||||||
|
|
||||||
const [analytics] = useState({
|
const handleDeleteJob = (id: string) => {
|
||||||
totalJobs: 284,
|
setJobs(jobs.filter((job) => job.id !== id));
|
||||||
activeApplications: 156,
|
};
|
||||||
totalUsers: 2341,
|
|
||||||
avgTimeToHire: "18 days", monthlyGrowth: "+12.5%", conversionRate: "8.3%"});
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleDeleteUser = (id: string) => {
|
||||||
window.location.href = "/";
|
setUsers(users.filter((user) => user.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,12 +110,12 @@ export default function AdminDashboard() {
|
|||||||
defaultButtonVariant="text-stagger"
|
defaultButtonVariant="text-stagger"
|
||||||
defaultTextAnimation="reveal-blur"
|
defaultTextAnimation="reveal-blur"
|
||||||
borderRadius="pill"
|
borderRadius="pill"
|
||||||
contentWidth="medium"
|
contentWidth="smallMedium"
|
||||||
sizing="mediumLargeSizeLargeTitles"
|
sizing="mediumLargeSizeLargeTitles"
|
||||||
background="none"
|
background="circleGradient"
|
||||||
cardStyle="solid"
|
cardStyle="gradient-radial"
|
||||||
primaryButtonStyle="gradient"
|
primaryButtonStyle="double-inset"
|
||||||
secondaryButtonStyle="solid"
|
secondaryButtonStyle="glass"
|
||||||
headingFontWeight="bold"
|
headingFontWeight="bold"
|
||||||
>
|
>
|
||||||
<div id="nav" data-section="nav">
|
<div id="nav" data-section="nav">
|
||||||
@@ -63,214 +123,60 @@ export default function AdminDashboard() {
|
|||||||
brandName="Jobee Admin"
|
brandName="Jobee Admin"
|
||||||
navItems={navItems}
|
navItems={navItems}
|
||||||
button={{
|
button={{
|
||||||
text: "Logout", onClick: handleLogout,
|
text: "Logout", onClick: () => console.log("Logout"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-screen bg-slate-50 pt-24 pb-12">
|
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100 py-16 px-4">
|
||||||
<div className="mx-auto max-w-7xl px-4">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-4xl font-bold text-slate-900 mb-8">Admin Dashboard</h1>
|
{/* Header */}
|
||||||
|
<div className="mb-12">
|
||||||
|
<h1 className="text-4xl font-bold text-slate-900 mb-2">Admin Dashboard</h1>
|
||||||
|
<p className="text-slate-600">Manage jobs, users, and view analytics</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
<div className="flex gap-4 mb-8 border-b border-slate-200">
|
<div className="flex gap-4 mb-8">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("jobs")}
|
onClick={() => setActiveTab("dashboard")}
|
||||||
className={`pb-4 px-4 font-semibold transition-colors ${
|
className={`px-6 py-3 rounded-lg font-semibold transition-all ${
|
||||||
activeTab === "jobs"
|
activeTab === "dashboard"
|
||||||
? "text-blue-600 border-b-2 border-blue-600"
|
? "bg-blue-600 text-white shadow-lg"
|
||||||
: "text-slate-600 hover:text-slate-900"
|
: "bg-white text-slate-700 hover:bg-slate-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<BarChart3 className="inline mr-2" size={20} />
|
||||||
<Briefcase size={20} />
|
Dashboard
|
||||||
Job Management
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("applications")}
|
onClick={() => setActiveTab("jobs")}
|
||||||
className={`pb-4 px-4 font-semibold transition-colors ${
|
className={`px-6 py-3 rounded-lg font-semibold transition-all ${
|
||||||
activeTab === "applications"
|
activeTab === "jobs"
|
||||||
? "text-blue-600 border-b-2 border-blue-600"
|
? "bg-blue-600 text-white shadow-lg"
|
||||||
: "text-slate-600 hover:text-slate-900"
|
: "bg-white text-slate-700 hover:bg-slate-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<Briefcase className="inline mr-2" size={20} />
|
||||||
<FileText size={20} />
|
Jobs ({jobs.length})
|
||||||
Applications
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("users")}
|
onClick={() => setActiveTab("users")}
|
||||||
className={`pb-4 px-4 font-semibold transition-colors ${
|
className={`px-6 py-3 rounded-lg font-semibold transition-all ${
|
||||||
activeTab === "users"
|
activeTab === "users"
|
||||||
? "text-blue-600 border-b-2 border-blue-600"
|
? "bg-blue-600 text-white shadow-lg"
|
||||||
: "text-slate-600 hover:text-slate-900"
|
: "bg-white text-slate-700 hover:bg-slate-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<Users className="inline mr-2" size={20} />
|
||||||
<Users size={20} />
|
Users ({users.length})
|
||||||
User Management
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("analytics")}
|
|
||||||
className={`pb-4 px-4 font-semibold transition-colors ${
|
|
||||||
activeTab === "analytics"
|
|
||||||
? "text-blue-600 border-b-2 border-blue-600"
|
|
||||||
: "text-slate-600 hover:text-slate-900"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<BarChart3 size={20} />
|
|
||||||
Analytics
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Job Management Tab */}
|
{/* Dashboard Tab */}
|
||||||
{activeTab === "jobs" && (
|
{activeTab === "dashboard" && (
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="bg-white rounded-lg p-6 shadow-md">
|
||||||
<h2 className="text-2xl font-bold text-slate-900">Job Listings</h2>
|
|
||||||
<button className="px-6 py-2 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 transition">
|
|
||||||
+ New Job
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="bg-slate-100 border-b border-slate-200">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Job Title</th>
|
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Company</th>
|
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Status</th>
|
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Applications</th>
|
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Posted</th>
|
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{jobs.map((job) => (
|
|
||||||
<tr key={job.id} className="border-b border-slate-200 hover:bg-slate-50">
|
|
||||||
<td className="px-6 py-4 text-sm text-slate-900 font-medium">{job.title}</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-slate-600">{job.company}</td>
|
|
||||||
<td className="px-6 py-4 text-sm">
|
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
|
||||||
job.status === "Active"
|
|
||||||
? "bg-green-100 text-green-700"
|
|
||||||
: "bg-gray-100 text-gray-700"
|
|
||||||
}`}>
|
|
||||||
{job.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-slate-600">{job.applications}</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-slate-600">{job.posted}</td>
|
|
||||||
<td className="px-6 py-4 text-sm space-x-2">
|
|
||||||
<button className="px-3 py-1 text-blue-600 hover:bg-blue-50 rounded transition">Edit</button>
|
|
||||||
<button className="px-3 py-1 text-red-600 hover:bg-red-50 rounded transition">Delete</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Applications Management Tab */}
|
|
||||||
{activeTab === "applications" && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h2 className="text-2xl font-bold text-slate-900 mb-6">Application Management</h2>
|
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="bg-slate-100 border-b border-slate-200">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Candidate</th>
|
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Position</th>
|
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Status</th>
|
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Applied Date</th>
|
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{applications.map((app) => (
|
|
||||||
<tr key={app.id} className="border-b border-slate-200 hover:bg-slate-50">
|
|
||||||
<td className="px-6 py-4 text-sm text-slate-900 font-medium">{app.candidate}</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-slate-600">{app.position}</td>
|
|
||||||
<td className="px-6 py-4 text-sm">
|
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
|
||||||
app.status === "Interview"
|
|
||||||
? "bg-blue-100 text-blue-700"
|
|
||||||
: app.status === "Under Review"
|
|
||||||
? "bg-yellow-100 text-yellow-700"
|
|
||||||
: "bg-red-100 text-red-700"
|
|
||||||
}`}>
|
|
||||||
{app.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-slate-600">{app.appliedDate}</td>
|
|
||||||
<td className="px-6 py-4 text-sm space-x-2">
|
|
||||||
<button className="px-3 py-1 text-blue-600 hover:bg-blue-50 rounded transition">View</button>
|
|
||||||
<button className="px-3 py-1 text-green-600 hover:bg-green-50 rounded transition">Approve</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* User Management Tab */}
|
|
||||||
{activeTab === "users" && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h2 className="text-2xl font-bold text-slate-900 mb-6">User Management</h2>
|
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="bg-slate-100 border-b border-slate-200">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Name</th>
|
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Email</th>
|
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Role</th>
|
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Joined</th>
|
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Status</th>
|
|
||||||
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{users.map((user) => (
|
|
||||||
<tr key={user.id} className="border-b border-slate-200 hover:bg-slate-50">
|
|
||||||
<td className="px-6 py-4 text-sm text-slate-900 font-medium">{user.name}</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-slate-600">{user.email}</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-slate-600">{user.role}</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-slate-600">{user.joined}</td>
|
|
||||||
<td className="px-6 py-4 text-sm">
|
|
||||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
|
||||||
user.status === "Active"
|
|
||||||
? "bg-green-100 text-green-700"
|
|
||||||
: "bg-gray-100 text-gray-700"
|
|
||||||
}`}>
|
|
||||||
{user.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm space-x-2">
|
|
||||||
<button className="px-3 py-1 text-blue-600 hover:bg-blue-50 rounded transition">Edit</button>
|
|
||||||
<button className="px-3 py-1 text-red-600 hover:bg-red-50 rounded transition">Disable</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Analytics Tab */}
|
|
||||||
{activeTab === "analytics" && (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h2 className="text-2xl font-bold text-slate-900 mb-6">Analytics Overview</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-blue-600">
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-600 text-sm font-medium">Total Jobs</p>
|
<p className="text-slate-600 text-sm font-medium">Total Jobs</p>
|
||||||
@@ -279,56 +185,165 @@ export default function AdminDashboard() {
|
|||||||
<Briefcase className="text-blue-600" size={40} />
|
<Briefcase className="text-blue-600" size={40} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-green-600">
|
<div className="bg-white rounded-lg p-6 shadow-md">
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-slate-600 text-sm font-medium">Active Applications</p>
|
|
||||||
<p className="text-3xl font-bold text-slate-900 mt-2">{analytics.activeApplications}</p>
|
|
||||||
</div>
|
|
||||||
<FileText className="text-green-600" size={40} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-purple-600">
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-600 text-sm font-medium">Total Users</p>
|
<p className="text-slate-600 text-sm font-medium">Total Users</p>
|
||||||
<p className="text-3xl font-bold text-slate-900 mt-2">{analytics.totalUsers}</p>
|
<p className="text-3xl font-bold text-slate-900 mt-2">{analytics.totalUsers}</p>
|
||||||
</div>
|
</div>
|
||||||
<Users className="text-purple-600" size={40} />
|
<Users className="text-green-600" size={40} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-yellow-600">
|
<div className="bg-white rounded-lg p-6 shadow-md">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-600 text-sm font-medium">Avg Time to Hire</p>
|
<p className="text-slate-600 text-sm font-medium">Active Applications</p>
|
||||||
<p className="text-3xl font-bold text-slate-900 mt-2">{analytics.avgTimeToHire}</p>
|
<p className="text-3xl font-bold text-slate-900 mt-2">{analytics.activeApplications}</p>
|
||||||
</div>
|
</div>
|
||||||
<TrendingUp className="text-yellow-600" size={40} />
|
<ChevronDown className="text-purple-600" size={40} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-red-600">
|
<div className="bg-white rounded-lg p-6 shadow-md">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-600 text-sm font-medium">Monthly Growth</p>
|
<p className="text-slate-600 text-sm font-medium">Successful Placements</p>
|
||||||
<p className="text-3xl font-bold text-slate-900 mt-2">{analytics.monthlyGrowth}</p>
|
<p className="text-3xl font-bold text-slate-900 mt-2">{analytics.successfulPlacements}</p>
|
||||||
</div>
|
</div>
|
||||||
<BarChart3 className="text-red-600" size={40} />
|
<BarChart3 className="text-orange-600" size={40} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg shadow p-6 border-l-4 border-indigo-600">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-slate-600 text-sm font-medium">Conversion Rate</p>
|
|
||||||
<p className="text-3xl font-bold text-slate-900 mt-2">{analytics.conversionRate}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<BarChart3 className="text-indigo-600" size={40} />
|
)}
|
||||||
|
|
||||||
|
{/* Jobs Tab */}
|
||||||
|
{activeTab === "jobs" && (
|
||||||
|
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-slate-200 flex justify-between items-center">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900">Job Management</h2>
|
||||||
|
<button className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all">
|
||||||
|
<Plus size={20} />
|
||||||
|
Add Job
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-slate-50 border-b border-slate-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Title</th>
|
||||||
|
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Company</th>
|
||||||
|
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Location</th>
|
||||||
|
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Status</th>
|
||||||
|
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Applications</th>
|
||||||
|
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<tr key={job.id} className="border-b border-slate-200 hover:bg-slate-50 transition-colors">
|
||||||
|
<td className="px-6 py-4 text-slate-900 font-medium">{job.title}</td>
|
||||||
|
<td className="px-6 py-4 text-slate-600">{job.company}</td>
|
||||||
|
<td className="px-6 py-4 text-slate-600">{job.location}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||||
|
job.status === "active"
|
||||||
|
? "bg-green-100 text-green-800"
|
||||||
|
: "bg-red-100 text-red-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{job.status.charAt(0).toUpperCase() + job.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-slate-600">{job.applications}</td>
|
||||||
|
<td className="px-6 py-4 flex gap-2">
|
||||||
|
<button className="p-2 text-blue-600 hover:bg-blue-50 rounded transition-colors">
|
||||||
|
<Edit2 size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteJob(job.id)}
|
||||||
|
className="p-2 text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Users Tab */}
|
||||||
|
{activeTab === "users" && (
|
||||||
|
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-slate-200 flex justify-between items-center">
|
||||||
|
<h2 className="text-2xl font-bold text-slate-900">User Management</h2>
|
||||||
|
<button className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all">
|
||||||
|
<Plus size={20} />
|
||||||
|
Add User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-slate-50 border-b border-slate-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Name</th>
|
||||||
|
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Email</th>
|
||||||
|
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Role</th>
|
||||||
|
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Join Date</th>
|
||||||
|
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Status</th>
|
||||||
|
<th className="px-6 py-3 text-left text-sm font-semibold text-slate-900">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<tr key={user.id} className="border-b border-slate-200 hover:bg-slate-50 transition-colors">
|
||||||
|
<td className="px-6 py-4 text-slate-900 font-medium">{user.name}</td>
|
||||||
|
<td className="px-6 py-4 text-slate-600">{user.email}</td>
|
||||||
|
<td className="px-6 py-4 text-slate-600">
|
||||||
|
<span className="capitalize">{user.role.replace("_", " ")}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-slate-600">{user.joinDate}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||||
|
user.status === "active"
|
||||||
|
? "bg-green-100 text-green-800"
|
||||||
|
: "bg-gray-100 text-gray-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 flex gap-2">
|
||||||
|
<button className="p-2 text-blue-600 hover:bg-blue-50 rounded transition-colors">
|
||||||
|
<Edit2 size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteUser(user.id)}
|
||||||
|
className="p-2 text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="footer" data-section="footer">
|
||||||
|
<FooterBase
|
||||||
|
logoText="Jobee"
|
||||||
|
copyrightText="© 2025 Jobee | Dutch Job Listing Platform"
|
||||||
|
columns={footerColumns}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,15 @@
|
|||||||
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
||||||
import NavbarStyleCentered from "@/components/navbar/NavbarStyleCentered/NavbarStyleCentered";
|
import NavbarStyleCentered from "@/components/navbar/NavbarStyleCentered/NavbarStyleCentered";
|
||||||
import FooterBase from "@/components/sections/footer/FooterBase";
|
import FooterBase from "@/components/sections/footer/FooterBase";
|
||||||
|
import { FileText, Clock, CheckCircle, XCircle, Mail } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Briefcase, Clock, CheckCircle, AlertCircle, MapPin, DollarSign, Building2, Calendar, ArrowRight } from "lucide-react";
|
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: "Search Jobs", id: "search" },
|
{ name: "Search Jobs", id: "search" },
|
||||||
{ name: "Post a Job", id: "post-job" },
|
{ name: "Post a Job", id: "/post-job" },
|
||||||
{ name: "Admin", id: "admin-login" },
|
{ name: "Applications", id: "/applications" },
|
||||||
{ name: "Browse", id: "browse" },
|
{ name: "Browse", id: "browse" },
|
||||||
{ name: "Contact", id: "contact" },
|
{ name: "Contact", id: "contact" },
|
||||||
{ name: "Applications", id: "/applications" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const footerColumns = [
|
const footerColumns = [
|
||||||
@@ -20,7 +19,7 @@ const footerColumns = [
|
|||||||
title: "Product", items: [
|
title: "Product", items: [
|
||||||
{ label: "Search Jobs", href: "/search" },
|
{ label: "Search Jobs", href: "/search" },
|
||||||
{ label: "Post a Job", href: "/post-job" },
|
{ label: "Post a Job", href: "/post-job" },
|
||||||
{ label: "Browse by Province", href: "#provinces" },
|
{ label: "My Applications", href: "/applications" },
|
||||||
{ label: "For Employers", href: "#" },
|
{ label: "For Employers", href: "#" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -46,81 +45,71 @@ interface Application {
|
|||||||
id: string;
|
id: string;
|
||||||
jobTitle: string;
|
jobTitle: string;
|
||||||
company: string;
|
company: string;
|
||||||
location: string;
|
status: "pending" | "reviewing" | "accepted" | "rejected";
|
||||||
salary: string;
|
|
||||||
appliedDate: string;
|
appliedDate: string;
|
||||||
status: "pending" | "reviewed" | "accepted" | "rejected";
|
lastUpdate: string;
|
||||||
logoSrc?: string;
|
applicantName: string;
|
||||||
|
email: string;
|
||||||
|
appliedPosition?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockApplications: Application[] = [
|
const mockApplications: Application[] = [
|
||||||
{
|
{
|
||||||
id: "1", jobTitle: "Senior React Developer", company: "TechFlow Solutions", location: "Amsterdam, Netherlands", salary: "€65,000 - €80,000", appliedDate: "2025-01-15", status: "reviewed", logoSrc: "https://api.dicebear.com/7.x/initials/svg?seed=TF"},
|
id: "1", jobTitle: "Senior Frontend Developer", company: "Tech Innovations BV", status: "reviewing", appliedDate: "2025-01-15", lastUpdate: "2025-01-18", applicantName: "John Doe", email: "john.doe@example.com"},
|
||||||
{
|
{
|
||||||
id: "2", jobTitle: "UX/UI Designer", company: "Creative Studios Amsterdam", location: "Amsterdam, Netherlands", salary: "€50,000 - €65,000", appliedDate: "2025-01-10", status: "accepted", logoSrc: "https://api.dicebear.com/7.x/initials/svg?seed=CSA"},
|
id: "2", jobTitle: "Product Manager", company: "Digital Solutions Inc", status: "pending", appliedDate: "2025-01-20", lastUpdate: "2025-01-20", applicantName: "Jane Smith", email: "jane.smith@example.com"},
|
||||||
{
|
{
|
||||||
id: "3", jobTitle: "Full Stack Developer", company: "Innovate Inc", location: "Rotterdam, Netherlands", salary: "€55,000 - €70,000", appliedDate: "2025-01-12", status: "pending", logoSrc: "https://api.dicebear.com/7.x/initials/svg?seed=II"},
|
id: "3", jobTitle: "UX/UI Designer", company: "Creative Studio Amsterdam", status: "accepted", appliedDate: "2025-01-10", lastUpdate: "2025-01-17", applicantName: "Alice Johnson", email: "alice.johnson@example.com"},
|
||||||
{
|
{
|
||||||
id: "4", jobTitle: "Marketing Manager", company: "Digital Growth Partners", location: "Utrecht, Netherlands", salary: "€48,000 - €60,000", appliedDate: "2025-01-08", status: "rejected", logoSrc: "https://api.dicebear.com/7.x/initials/svg?seed=DGP"},
|
id: "4", jobTitle: "Data Scientist", company: "AI Labs Netherlands", status: "rejected", appliedDate: "2025-01-05", lastUpdate: "2025-01-16", applicantName: "Bob Wilson", email: "bob.wilson@example.com"},
|
||||||
{
|
{
|
||||||
id: "5", jobTitle: "Data Scientist", company: "Analytics Pro", location: "The Hague, Netherlands", salary: "€60,000 - €75,000", appliedDate: "2025-01-05", status: "reviewed", logoSrc: "https://api.dicebear.com/7.x/initials/svg?seed=AP"},
|
id: "5", jobTitle: "Backend Developer", company: "Cloud Systems Ltd", status: "reviewing", appliedDate: "2025-01-12", lastUpdate: "2025-01-19", applicantName: "Charlie Brown", email: "charlie.brown@example.com"},
|
||||||
{
|
|
||||||
id: "6", jobTitle: "Product Manager", company: "Tech Ventures", location: "Eindhoven, Netherlands", salary: "€65,000 - €85,000", appliedDate: "2025-01-02", status: "pending", logoSrc: "https://api.dicebear.com/7.x/initials/svg?seed=TV"},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
export default function ApplicationsPage() {
|
||||||
|
const [applications, setApplications] = useState<Application[]>(mockApplications);
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||||
|
const [selectedApp, setSelectedApp] = useState<Application | null>(null);
|
||||||
|
|
||||||
|
const filteredApplications =
|
||||||
|
filterStatus === "all"
|
||||||
|
? applications
|
||||||
|
: applications.filter((app) => app.status === filterStatus);
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return <Clock className="w-5 h-5 text-yellow-600" />;
|
||||||
|
case "reviewing":
|
||||||
|
return <FileText className="w-5 h-5 text-blue-600" />;
|
||||||
|
case "accepted":
|
||||||
|
return <CheckCircle className="w-5 h-5 text-green-600" />;
|
||||||
|
case "rejected":
|
||||||
|
return <XCircle className="w-5 h-5 text-red-600" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadgeColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return "bg-yellow-100 text-yellow-800";
|
||||||
|
case "reviewing":
|
||||||
|
return "bg-blue-100 text-blue-800";
|
||||||
case "accepted":
|
case "accepted":
|
||||||
return "bg-green-100 text-green-800";
|
return "bg-green-100 text-green-800";
|
||||||
case "rejected":
|
case "rejected":
|
||||||
return "bg-red-100 text-red-800";
|
return "bg-red-100 text-red-800";
|
||||||
case "reviewed":
|
|
||||||
return "bg-blue-100 text-blue-800";
|
|
||||||
case "pending":
|
|
||||||
return "bg-yellow-100 text-yellow-800";
|
|
||||||
default:
|
default:
|
||||||
return "bg-gray-100 text-gray-800";
|
return "bg-slate-100 text-slate-800";
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case "accepted":
|
|
||||||
return <CheckCircle className="w-4 h-4" />;
|
|
||||||
case "rejected":
|
|
||||||
return <AlertCircle className="w-4 h-4" />;
|
|
||||||
case "reviewed":
|
|
||||||
return <Clock className="w-4 h-4" />;
|
|
||||||
case "pending":
|
|
||||||
return <Clock className="w-4 h-4" />;
|
|
||||||
default:
|
|
||||||
return <Clock className="w-4 h-4" />;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusLabel = (status: string) => {
|
const getStatusLabel = (status: string) => {
|
||||||
switch (status) {
|
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||||
case "accepted":
|
|
||||||
return "Accepted";
|
|
||||||
case "rejected":
|
|
||||||
return "Rejected";
|
|
||||||
case "reviewed":
|
|
||||||
return "Under Review";
|
|
||||||
case "pending":
|
|
||||||
return "Pending";
|
|
||||||
default:
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ApplicationsPage() {
|
|
||||||
const [selectedApplication, setSelectedApplication] = useState<Application | null>(null);
|
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
|
||||||
|
|
||||||
const filteredApplications = statusFilter === "all"
|
|
||||||
? mockApplications
|
|
||||||
: mockApplications.filter(app => app.status === statusFilter);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
defaultButtonVariant="text-stagger"
|
defaultButtonVariant="text-stagger"
|
||||||
@@ -143,136 +132,86 @@ export default function ApplicationsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main className="min-h-screen bg-gradient-to-br from-background via-card to-background">
|
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100 pt-32 pb-20">
|
||||||
<div className="container mx-auto px-4 py-16">
|
<div className="max-w-6xl mx-auto px-4">
|
||||||
{/* Header Section */}
|
<div className="mb-8">
|
||||||
<div className="mb-12">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<Briefcase className="w-8 h-8 text-primary-cta" />
|
<Mail className="w-6 h-6 text-blue-600" />
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-foreground">My Applications</h1>
|
<span className="text-sm font-semibold text-blue-600 uppercase tracking-wide">
|
||||||
|
My Applications
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg text-foreground/70 mb-8">
|
<h1 className="text-4xl md:text-5xl font-bold text-slate-900 mb-3">
|
||||||
Track and manage all your job applications in one place. Monitor your application status and stay updated on opportunities.
|
Track Your Applications
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-slate-600">
|
||||||
|
Monitor the status of all your job applications and stay updated on each
|
||||||
|
opportunity.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Status Filter */}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setStatusFilter("all")}
|
|
||||||
className={`px-6 py-2 rounded-full font-medium transition-all ${
|
|
||||||
statusFilter === "all"
|
|
||||||
? "bg-primary-cta text-white"
|
|
||||||
: "bg-card border border-accent/30 text-foreground hover:border-accent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
All Applications ({mockApplications.length})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setStatusFilter("pending")}
|
|
||||||
className={`px-6 py-2 rounded-full font-medium transition-all ${
|
|
||||||
statusFilter === "pending"
|
|
||||||
? "bg-yellow-500 text-white"
|
|
||||||
: "bg-card border border-accent/30 text-foreground hover:border-accent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Pending
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setStatusFilter("reviewed")}
|
|
||||||
className={`px-6 py-2 rounded-full font-medium transition-all ${
|
|
||||||
statusFilter === "reviewed"
|
|
||||||
? "bg-blue-500 text-white"
|
|
||||||
: "bg-card border border-accent/30 text-foreground hover:border-accent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Under Review
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setStatusFilter("accepted")}
|
|
||||||
className={`px-6 py-2 rounded-full font-medium transition-all ${
|
|
||||||
statusFilter === "accepted"
|
|
||||||
? "bg-green-500 text-white"
|
|
||||||
: "bg-card border border-accent/30 text-foreground hover:border-accent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Accepted
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setStatusFilter("rejected")}
|
|
||||||
className={`px-6 py-2 rounded-full font-medium transition-all ${
|
|
||||||
statusFilter === "rejected"
|
|
||||||
? "bg-red-500 text-white"
|
|
||||||
: "bg-card border border-accent/30 text-foreground hover:border-accent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Rejected
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Applications Grid */}
|
{/* Filter Tabs */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="mb-8 flex flex-wrap gap-3">
|
||||||
{/* Application List */}
|
{[
|
||||||
<div className="lg:col-span-2">
|
{ label: "All", value: "all", count: applications.length },
|
||||||
|
{
|
||||||
|
label: "Pending", value: "pending", count: applications.filter((a) => a.status === "pending").length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Reviewing", value: "reviewing", count: applications.filter((a) => a.status === "reviewing").length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Accepted", value: "accepted", count: applications.filter((a) => a.status === "accepted").length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Rejected", value: "rejected", count: applications.filter((a) => a.status === "rejected").length,
|
||||||
|
},
|
||||||
|
].map((filter) => (
|
||||||
|
<button
|
||||||
|
key={filter.value}
|
||||||
|
onClick={() => setFilterStatus(filter.value)}
|
||||||
|
className={`px-4 py-2 rounded-lg font-semibold transition ${
|
||||||
|
filterStatus === filter.value
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "bg-white text-slate-700 hover:bg-slate-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{filter.label} ({filter.count})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Applications List */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{filteredApplications.length === 0 ? (
|
{filteredApplications.length === 0 ? (
|
||||||
<div className="bg-card border border-accent/20 rounded-2xl p-8 text-center">
|
<div className="text-center py-12 bg-white rounded-lg">
|
||||||
<Briefcase className="w-12 h-12 text-foreground/30 mx-auto mb-4" />
|
<p className="text-slate-600 text-lg">No applications found</p>
|
||||||
<p className="text-foreground/60">No applications found with this status.</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredApplications.map((app) => (
|
filteredApplications.map((app) => (
|
||||||
<div
|
<div
|
||||||
key={app.id}
|
key={app.id}
|
||||||
onClick={() => setSelectedApplication(app)}
|
className="bg-white rounded-lg shadow hover:shadow-md transition p-6 cursor-pointer"
|
||||||
className={`bg-card border border-accent/20 rounded-2xl p-6 cursor-pointer transition-all hover:border-accent/50 hover:shadow-lg ${
|
onClick={() => setSelectedApp(app)}
|
||||||
selectedApplication?.id === app.id ? "border-primary-cta" : ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex gap-4">
|
<div className="flex items-start justify-between">
|
||||||
{/* Company Logo */}
|
<div className="flex-1">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<div className="w-16 h-16 bg-gradient-to-br from-primary-cta/20 to-accent/20 rounded-xl flex items-center justify-center">
|
<h3 className="text-xl font-bold text-slate-900">{app.jobTitle}</h3>
|
||||||
<Building2 className="w-8 h-8 text-primary-cta" />
|
<div
|
||||||
</div>
|
className={`flex items-center gap-1 px-3 py-1 rounded-full text-sm font-semibold ${
|
||||||
</div>
|
getStatusBadgeColor(app.status)
|
||||||
|
|
||||||
{/* Application Info */}
|
|
||||||
<div className="flex-grow">
|
|
||||||
<div className="flex items-start justify-between mb-2">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-bold text-foreground">{app.jobTitle}</h3>
|
|
||||||
<p className="text-sm text-foreground/60">{app.company}</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`px-3 py-1 rounded-full text-xs font-medium flex items-center gap-1 ${
|
|
||||||
getStatusColor(app.status)
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{getStatusIcon(app.status)}
|
{getStatusIcon(app.status)}
|
||||||
{getStatusLabel(app.status)}
|
{getStatusLabel(app.status)}
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 text-sm text-foreground/60 mt-3">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<MapPin className="w-4 h-4" />
|
|
||||||
{app.location}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<DollarSign className="w-4 h-4" />
|
|
||||||
{app.salary}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Calendar className="w-4 h-4" />
|
|
||||||
{new Date(app.appliedDate).toLocaleDateString()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-slate-600 mb-2">{app.company}</p>
|
||||||
|
<div className="text-sm text-slate-500 space-y-1">
|
||||||
|
<p>Applied: {new Date(app.appliedDate).toLocaleDateString()}</p>
|
||||||
|
<p>Last Update: {new Date(app.lastUpdate).toLocaleDateString()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Arrow Icon */}
|
|
||||||
<div className="flex-shrink-0 flex items-center">
|
|
||||||
<ArrowRight className="w-5 h-5 text-foreground/30" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,68 +220,71 @@ export default function ApplicationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Application Details Panel */}
|
{/* Detail Modal */}
|
||||||
<div className="lg:col-span-1">
|
{selectedApp && (
|
||||||
{selectedApplication ? (
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
<div className="bg-card border border-accent/20 rounded-2xl p-6 sticky top-24">
|
<div className="bg-white rounded-lg max-w-2xl w-full max-h-96 overflow-y-auto">
|
||||||
<div className="mb-6">
|
<div className="p-8">
|
||||||
<h2 className="text-2xl font-bold text-foreground mb-2">{selectedApplication.jobTitle}</h2>
|
<div className="flex items-start justify-between mb-6">
|
||||||
<p className="text-foreground/60 mb-4">{selectedApplication.company}</p>
|
<div>
|
||||||
<span
|
<h2 className="text-2xl font-bold text-slate-900 mb-2">
|
||||||
className={`inline-flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium ${
|
{selectedApp.jobTitle}
|
||||||
getStatusColor(selectedApplication.status)
|
</h2>
|
||||||
|
<p className="text-slate-600 text-lg">{selectedApp.company}</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-full font-semibold ${
|
||||||
|
getStatusBadgeColor(selectedApp.status)
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{getStatusIcon(selectedApplication.status)}
|
{getStatusIcon(selectedApp.status)}
|
||||||
{getStatusLabel(selectedApplication.status)}
|
{getStatusLabel(selectedApp.status)}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 border-t border-accent/10 pt-6">
|
<div className="border-t pt-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold text-foreground/50 uppercase tracking-wide mb-2">Location</p>
|
<p className="text-sm text-slate-600">Applicant Name</p>
|
||||||
<div className="flex items-center gap-2">
|
<p className="text-lg font-semibold text-slate-900">{selectedApp.applicantName}</p>
|
||||||
<MapPin className="w-4 h-4 text-primary-cta" />
|
|
||||||
<p className="text-foreground">{selectedApplication.location}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold text-foreground/50 uppercase tracking-wide mb-2">Salary Range</p>
|
<p className="text-sm text-slate-600">Email Address</p>
|
||||||
<div className="flex items-center gap-2">
|
<p className="text-lg font-semibold text-slate-900">{selectedApp.email}</p>
|
||||||
<DollarSign className="w-4 h-4 text-primary-cta" />
|
|
||||||
<p className="text-foreground">{selectedApplication.salary}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold text-foreground/50 uppercase tracking-wide mb-2">Applied Date</p>
|
<p className="text-sm text-slate-600">Applied Date</p>
|
||||||
<div className="flex items-center gap-2">
|
<p className="text-lg font-semibold text-slate-900">
|
||||||
<Calendar className="w-4 h-4 text-primary-cta" />
|
{new Date(selectedApp.appliedDate).toLocaleDateString()}
|
||||||
<p className="text-foreground">{new Date(selectedApplication.appliedDate).toLocaleDateString()}</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-600">Last Update</p>
|
||||||
|
<p className="text-lg font-semibold text-slate-900">
|
||||||
|
{new Date(selectedApp.lastUpdate).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 space-y-2">
|
<div className="border-t mt-6 pt-6">
|
||||||
<button className="w-full bg-primary-cta text-white py-3 rounded-lg font-medium hover:opacity-90 transition-opacity">
|
<div className="flex gap-3">
|
||||||
View Application
|
<button
|
||||||
|
onClick={() => setSelectedApp(null)}
|
||||||
|
className="flex-1 bg-slate-200 hover:bg-slate-300 text-slate-900 font-semibold py-2 rounded-lg transition"
|
||||||
|
>
|
||||||
|
Close
|
||||||
</button>
|
</button>
|
||||||
<button className="w-full bg-card border border-accent/30 text-foreground py-3 rounded-lg font-medium hover:border-accent transition-colors">
|
<button className="flex-1 bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 rounded-lg transition">
|
||||||
Contact Company
|
View Full Details
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div className="bg-card border border-accent/20 rounded-2xl p-6 text-center">
|
</div>
|
||||||
<Briefcase className="w-12 h-12 text-foreground/30 mx-auto mb-4" />
|
|
||||||
<p className="text-foreground/60">Select an application to view details</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<div id="footer" data-section="footer">
|
<div id="footer" data-section="footer">
|
||||||
<FooterBase
|
<FooterBase
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import NavbarStyleCentered from "@/components/navbar/NavbarStyleCentered/NavbarS
|
|||||||
import FeatureCardEight from "@/components/sections/feature/FeatureCardEight";
|
import FeatureCardEight from "@/components/sections/feature/FeatureCardEight";
|
||||||
import TestimonialCardTwo from "@/components/sections/testimonial/TestimonialCardTwo";
|
import TestimonialCardTwo from "@/components/sections/testimonial/TestimonialCardTwo";
|
||||||
import FooterBase from "@/components/sections/footer/FooterBase";
|
import FooterBase from "@/components/sections/footer/FooterBase";
|
||||||
import { Sparkles } from "lucide-react";
|
import { Briefcase, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
export default function ApplyPage() {
|
export default function ApplyPage() {
|
||||||
const navItems = [
|
const navItems = [
|
||||||
|
|||||||
380
src/app/jobs/page.tsx
Normal file
380
src/app/jobs/page.tsx
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
||||||
|
import NavbarStyleCentered from "@/components/navbar/NavbarStyleCentered/NavbarStyleCentered";
|
||||||
|
import FooterBase from "@/components/sections/footer/FooterBase";
|
||||||
|
import { MapPin, DollarSign, Briefcase, Heart } from "lucide-react";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ name: "Search Jobs", id: "/search" },
|
||||||
|
{ name: "Post a Job", id: "/post-job" },
|
||||||
|
{ name: "Admin", id: "/admin-login" },
|
||||||
|
{ name: "Browse", id: "browse" },
|
||||||
|
{ name: "Contact", id: "contact" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const footerColumns = [
|
||||||
|
{
|
||||||
|
title: "Product", items: [
|
||||||
|
{ label: "Search Jobs", href: "/search" },
|
||||||
|
{ label: "Post a Job", href: "/post-job" },
|
||||||
|
{ label: "Browse by Province", href: "#provinces" },
|
||||||
|
{ label: "For Employers", href: "#" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Company", items: [
|
||||||
|
{ label: "About Jobee", href: "#about" },
|
||||||
|
{ label: "Careers", href: "#" },
|
||||||
|
{ label: "Contact Us", href: "#contact" },
|
||||||
|
{ label: "Blog", href: "#" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Resources", items: [
|
||||||
|
{ label: "Privacy Policy", href: "#" },
|
||||||
|
{ label: "Terms of Service", href: "#" },
|
||||||
|
{ label: "FAQ", href: "#" },
|
||||||
|
{ label: "Support", href: "#" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockJobs = [
|
||||||
|
{
|
||||||
|
id: "1", title: "Senior Software Engineer", company: "TechCorp Amsterdam", location: "Amsterdam", province: "North Holland", salary: "€80,000 - €120,000", salaryMin: 80000,
|
||||||
|
salaryMax: 120000,
|
||||||
|
jobType: "Full-time", category: "Technology", description:
|
||||||
|
"Join our innovative team as a Senior Software Engineer. We\'re looking for experienced developers with expertise in React, Node.js, and cloud technologies.", fullDescription:
|
||||||
|
"Join our innovative team as a Senior Software Engineer. We\'re looking for experienced developers with expertise in React, Node.js, and cloud technologies. This role offers the opportunity to work on cutting-edge projects, mentor junior developers, and contribute to architectural decisions.", requirements: [
|
||||||
|
"5+ years of software development experience", "Proficiency in React, Node.js, and TypeScript", "Experience with cloud platforms (AWS, GCP, Azure)", "Strong understanding of software design patterns", "Excellent communication skills"],
|
||||||
|
benefits: [
|
||||||
|
"Competitive salary and bonus structure", "Health insurance and wellness programs", "Flexible working hours and remote work options", "Professional development opportunities", "Modern office in Amsterdam city center"],
|
||||||
|
logo: "http://img.b2bpic.net/free-vector/tech-company-logo_23-2148947635.jpg", postedDate: "2 days ago"},
|
||||||
|
{
|
||||||
|
id: "2", title: "Marketing Manager", company: "Creative Solutions Rotterdam", location: "Rotterdam", province: "South Holland", salary: "€60,000 - €85,000", salaryMin: 60000,
|
||||||
|
salaryMax: 85000,
|
||||||
|
jobType: "Full-time", category: "Marketing", description:
|
||||||
|
"Lead our marketing initiatives and drive brand growth. We seek a strategic marketer with experience in digital marketing and campaign management.", fullDescription:
|
||||||
|
"Lead our marketing initiatives and drive brand growth. We seek a strategic marketer with experience in digital marketing and campaign management. You\'ll oversee a team of marketing professionals and develop comprehensive marketing strategies to achieve business objectives.", requirements: [
|
||||||
|
"7+ years of marketing management experience", "Proven track record in digital marketing campaigns", "Strong analytical and data-driven decision making", "Leadership and team management experience", "Proficiency in marketing automation tools"],
|
||||||
|
benefits: [
|
||||||
|
"Competitive salary package", "Team leadership opportunities", "Conference and training budget", "Flexible working arrangement", "Performance-based bonuses"],
|
||||||
|
logo: "http://img.b2bpic.net/free-vector/marketing-logo_23-2148947635.jpg", postedDate: "5 days ago"},
|
||||||
|
{
|
||||||
|
id: "3", title: "Data Scientist", company: "Analytics Pro Utrecht", location: "Utrecht", province: "Utrecht", salary: "€70,000 - €110,000", salaryMin: 70000,
|
||||||
|
salaryMax: 110000,
|
||||||
|
jobType: "Full-time", category: "Data Science", description:
|
||||||
|
"Develop advanced analytics solutions for our global clients. Expertise in Python, machine learning, and big data technologies required.", fullDescription:
|
||||||
|
"Develop advanced analytics solutions for our global clients. Expertise in Python, machine learning, and big data technologies required. Work on complex data challenges, build predictive models, and transform raw data into actionable insights.", requirements: [
|
||||||
|
"Advanced degree in Computer Science, Mathematics, or related field", "Proficiency in Python and machine learning libraries", "Experience with big data technologies (Spark, Hadoop)", "Strong SQL and database knowledge", "Experience with data visualization tools"],
|
||||||
|
benefits: [
|
||||||
|
"Excellent salary and benefits", "State-of-the-art computing resources", "Continuous learning opportunities", "Collaborative research environment", "Publication opportunities"],
|
||||||
|
logo: "http://img.b2bpic.net/free-vector/analytics-logo_23-2148947635.jpg", postedDate: "1 week ago"},
|
||||||
|
{
|
||||||
|
id: "4", title: "UX/UI Designer", company: "Design Studios The Hague", location: "The Hague", province: "South Holland", salary: "€55,000 - €75,000", salaryMin: 55000,
|
||||||
|
salaryMax: 75000,
|
||||||
|
jobType: "Full-time", category: "Design", description:
|
||||||
|
"Create beautiful and intuitive user experiences for our web and mobile applications. Portfolio required.", fullDescription:
|
||||||
|
"Create beautiful and intuitive user experiences for our web and mobile applications. Portfolio required. Collaborate with product teams, conduct user research, and iterate on designs based on user feedback and analytics.", requirements: [
|
||||||
|
"4+ years of UX/UI design experience", "Proficiency in design tools (Figma, Adobe XD)", "Strong portfolio showcasing design work", "Understanding of user research and testing methodologies", "Knowledge of responsive design principles"],
|
||||||
|
benefits: [
|
||||||
|
"Creative and collaborative work environment", "Access to latest design tools and technologies", "Design conference attendance budget", "Flexible work schedule", "Health and wellness benefits"],
|
||||||
|
logo: "http://img.b2bpic.net/free-vector/design-logo_23-2148947635.jpg", postedDate: "3 days ago"},
|
||||||
|
{
|
||||||
|
id: "5", title: "Sales Executive", company: "Enterprise Solutions Groningen", location: "Groningen", province: "Groningen", salary: "€45,000 - €70,000", salaryMin: 45000,
|
||||||
|
salaryMax: 70000,
|
||||||
|
jobType: "Full-time", category: "Sales", description:
|
||||||
|
"Grow our sales pipeline and build strong client relationships. Commission and bonus structure available.", fullDescription:
|
||||||
|
"Grow our sales pipeline and build strong client relationships. Commission and bonus structure available. Manage a portfolio of accounts, identify new business opportunities, and achieve sales targets.", requirements: [
|
||||||
|
"3+ years of B2B sales experience", "Strong negotiation and closing skills", "CRM software proficiency", "Excellent communication abilities", "Self-motivated and target-driven"],
|
||||||
|
benefits: [
|
||||||
|
"Competitive base salary plus commission", "Performance bonuses", "Sales training and development", "Company car or allowance", "International travel opportunities"],
|
||||||
|
logo: "http://img.b2bpic.net/free-vector/sales-logo_23-2148947635.jpg", postedDate: "4 days ago"},
|
||||||
|
{
|
||||||
|
id: "6", title: "HR Specialist", company: "People First Leiden", location: "Leiden", province: "South Holland", salary: "€50,000 - €65,000", salaryMin: 50000,
|
||||||
|
salaryMax: 65000,
|
||||||
|
jobType: "Part-time", category: "Human Resources", description:
|
||||||
|
"Support our HR team in recruitment, onboarding, and employee development initiatives.", fullDescription:
|
||||||
|
"Support our HR team in recruitment, onboarding, and employee development initiatives. Handle administrative HR tasks, coordinate interviews, and support employee relations.", requirements: [
|
||||||
|
"3+ years of HR experience", "Knowledge of Dutch employment law", "Proficiency in HR management systems", "Strong organizational skills", "Excellent interpersonal abilities"],
|
||||||
|
benefits: [
|
||||||
|
"Competitive part-time salary", "Flexible working hours", "Professional HR certifications support", "Health insurance", "Staff development programs"],
|
||||||
|
logo: "http://img.b2bpic.net/free-vector/hr-logo_23-2148947635.jpg", postedDate: "1 week ago"},
|
||||||
|
{
|
||||||
|
id: "7", title: "DevOps Engineer", company: "Cloud Innovations Eindhoven", location: "Eindhoven", province: "North Brabant", salary: "€75,000 - €105,000", salaryMin: 75000,
|
||||||
|
salaryMax: 105000,
|
||||||
|
jobType: "Full-time", category: "Technology", description:
|
||||||
|
"Manage and optimize our cloud infrastructure. Experience with Docker, Kubernetes, and CI/CD pipelines required.", fullDescription:
|
||||||
|
"Manage and optimize our cloud infrastructure. Experience with Docker, Kubernetes, and CI/CD pipelines required. Build and maintain deployment pipelines, monitor system performance, and implement security best practices.", requirements: [
|
||||||
|
"5+ years of DevOps experience", "Expert knowledge of Docker and Kubernetes", "CI/CD pipeline implementation experience", "Strong Linux administration skills", "Cloud platform experience (AWS/GCP/Azure)"],
|
||||||
|
benefits: [
|
||||||
|
"High competitive salary", "Remote work options", "Technical certification support", "Modern tech stack", "Collaborative engineering team"],
|
||||||
|
logo: "http://img.b2bpic.net/free-vector/devops-logo_23-2148947635.jpg", postedDate: "6 days ago"},
|
||||||
|
{
|
||||||
|
id: "8", title: "Product Manager", company: "Innovation Lab Delft", location: "Delft", province: "South Holland", salary: "€70,000 - €95,000", salaryMin: 70000,
|
||||||
|
salaryMax: 95000,
|
||||||
|
jobType: "Full-time", category: "Product", description:
|
||||||
|
"Lead product strategy and roadmap development for our SaaS platform. Experience with agile methodologies required.", fullDescription:
|
||||||
|
"Lead product strategy and roadmap development for our SaaS platform. Experience with agile methodologies required. Work cross-functionally with engineering, design, and marketing teams to deliver customer-centric solutions.", requirements: [
|
||||||
|
"5+ years of product management experience", "Expertise in SaaS business models", "Proficiency in product management tools", "Strong analytical skills", "Experience with agile and Scrum methodologies"],
|
||||||
|
benefits: [
|
||||||
|
"Attractive salary and equity", "Leadership development programs", "Industry conference attendance", "Flexible work arrangement", "Collaborative innovation environment"],
|
||||||
|
logo: "http://img.b2bpic.net/free-vector/product-logo_23-2148947635.jpg", postedDate: "2 days ago"},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface JobCardProps {
|
||||||
|
job: (typeof mockJobs)[0];
|
||||||
|
isFavorited: boolean;
|
||||||
|
onFavorite: (id: string) => void;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobCard({ job, isFavorited, onFavorite, onClick }: JobCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className="p-6 rounded-xl bg-card border border-accent/20 hover:border-accent/50 transition-all duration-300 hover:shadow-lg cursor-pointer group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-start gap-4 flex-1">
|
||||||
|
<div className="w-16 h-16 rounded-lg bg-gradient-to-br from-primary-cta to-secondary-cta flex items-center justify-center text-white font-bold text-xl flex-shrink-0 group-hover:shadow-lg transition-shadow">
|
||||||
|
{job.company.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-xl font-bold text-foreground mb-1 group-hover:text-primary-cta transition-colors">
|
||||||
|
{job.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-foreground/70 mb-2">{job.company}</p>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-foreground/60">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<MapPin size={16} />
|
||||||
|
{job.location}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Briefcase size={16} />
|
||||||
|
{job.jobType}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onFavorite(job.id);
|
||||||
|
}}
|
||||||
|
className="p-2 rounded-full hover:bg-accent/10 transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
size={20}
|
||||||
|
className={isFavorited ? "fill-primary-cta text-primary-cta" : "text-foreground/50"}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-foreground/70 mb-4 line-clamp-2">
|
||||||
|
{job.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<span className="inline-block px-3 py-1 rounded-lg text-xs bg-background text-foreground/70">
|
||||||
|
{job.category}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-primary-cta flex items-center gap-1">
|
||||||
|
<DollarSign size={14} />
|
||||||
|
{job.salary}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 rounded-full bg-primary-cta text-white font-semibold hover:opacity-90 transition-opacity text-sm">
|
||||||
|
Apply Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JobListingPage() {
|
||||||
|
const [selectedJob, setSelectedJob] = useState<(typeof mockJobs)[0] | null>(null);
|
||||||
|
const [favorites, setFavorites] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const handleFavorite = (jobId: string) => {
|
||||||
|
const newFavorites = new Set(favorites);
|
||||||
|
if (newFavorites.has(jobId)) {
|
||||||
|
newFavorites.delete(jobId);
|
||||||
|
} else {
|
||||||
|
newFavorites.add(jobId);
|
||||||
|
}
|
||||||
|
setFavorites(newFavorites);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider
|
||||||
|
defaultButtonVariant="text-stagger"
|
||||||
|
defaultTextAnimation="reveal-blur"
|
||||||
|
borderRadius="pill"
|
||||||
|
contentWidth="smallMedium"
|
||||||
|
sizing="mediumLargeSizeLargeTitles"
|
||||||
|
background="circleGradient"
|
||||||
|
cardStyle="gradient-radial"
|
||||||
|
primaryButtonStyle="double-inset"
|
||||||
|
secondaryButtonStyle="glass"
|
||||||
|
headingFontWeight="bold"
|
||||||
|
>
|
||||||
|
<div id="nav" data-section="nav">
|
||||||
|
<NavbarStyleCentered
|
||||||
|
brandName="Jobee"
|
||||||
|
navItems={navItems}
|
||||||
|
button={{
|
||||||
|
text: "Post a Job", href: "/post-job"}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-background to-background-accent pt-20 pb-20">
|
||||||
|
<div className="max-w-7xl mx-auto px-4">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{/* Job Listings Column */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold text-foreground mb-2">
|
||||||
|
Latest Job Listings
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-foreground/70">
|
||||||
|
{mockJobs.length} positions available
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{mockJobs.map((job) => (
|
||||||
|
<JobCard
|
||||||
|
key={job.id}
|
||||||
|
job={job}
|
||||||
|
isFavorited={favorites.has(job.id)}
|
||||||
|
onFavorite={handleFavorite}
|
||||||
|
onClick={() => setSelectedJob(job)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Job Details Column */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
{selectedJob ? (
|
||||||
|
<div className="sticky top-28 p-6 rounded-xl bg-card border border-accent/20">
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="w-20 h-20 rounded-lg bg-gradient-to-br from-primary-cta to-secondary-cta flex items-center justify-center text-white font-bold text-2xl mb-4">
|
||||||
|
{selectedJob.company.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-foreground mb-2">
|
||||||
|
{selectedJob.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-foreground/70 mb-4">{selectedJob.company}</p>
|
||||||
|
<p className="text-2xl font-bold text-primary-cta mb-4">
|
||||||
|
{selectedJob.salary}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleFavorite(selectedJob.id);
|
||||||
|
}}
|
||||||
|
className="w-full py-3 rounded-full bg-primary-cta/10 text-primary-cta font-semibold hover:bg-primary-cta/20 transition-colors flex items-center justify-center gap-2 mb-3"
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
size={18}
|
||||||
|
className={favorites.has(selectedJob.id) ? "fill-primary-cta" : ""}
|
||||||
|
/>
|
||||||
|
{favorites.has(selectedJob.id) ? "Saved" : "Save Job"}
|
||||||
|
</button>
|
||||||
|
<button className="w-full py-3 rounded-full bg-primary-cta text-white font-semibold hover:opacity-90 transition-opacity">
|
||||||
|
Apply Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-accent/20 pt-6">
|
||||||
|
<h3 className="text-lg font-bold text-foreground mb-4">
|
||||||
|
Job Details
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-foreground/60 mb-1">Posted</p>
|
||||||
|
<p className="text-foreground font-semibold">
|
||||||
|
{selectedJob.postedDate}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-foreground/60 mb-1">Location</p>
|
||||||
|
<p className="text-foreground font-semibold">
|
||||||
|
{selectedJob.location}, {selectedJob.province}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-foreground/60 mb-1">Job Type</p>
|
||||||
|
<p className="text-foreground font-semibold">
|
||||||
|
{selectedJob.jobType}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-foreground/60 mb-1">Category</p>
|
||||||
|
<p className="text-foreground font-semibold">
|
||||||
|
{selectedJob.category}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-accent/20 pt-6 mt-6">
|
||||||
|
<h3 className="text-lg font-bold text-foreground mb-4">
|
||||||
|
Requirements
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{selectedJob.requirements.map((req, idx) => (
|
||||||
|
<li key={idx} className="flex gap-3 text-foreground/70">
|
||||||
|
<span className="text-primary-cta font-bold flex-shrink-0">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
<span>{req}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-accent/20 pt-6 mt-6">
|
||||||
|
<h3 className="text-lg font-bold text-foreground mb-4">
|
||||||
|
Benefits
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{selectedJob.benefits.map((benefit, idx) => (
|
||||||
|
<li key={idx} className="flex gap-3 text-foreground/70">
|
||||||
|
<span className="text-primary-cta font-bold flex-shrink-0">
|
||||||
|
★
|
||||||
|
</span>
|
||||||
|
<span>{benefit}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="sticky top-28 p-6 rounded-xl bg-card border border-accent/20 border-dashed text-center">
|
||||||
|
<p className="text-foreground/60">
|
||||||
|
Select a job to view full details
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="footer" data-section="footer">
|
||||||
|
<FooterBase
|
||||||
|
logoText="Jobee"
|
||||||
|
copyrightText="© 2025 Jobee | Dutch Job Listing Platform"
|
||||||
|
columns={footerColumns}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import "./styles/variables.css";
|
|
||||||
import "./styles/base.css";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({ variable: "--font-inter", subsets: ["latin"] });
|
||||||
variable: "--font-inter", subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Jobee - Find Your Dream Job in the Netherlands", description:
|
title: "Jobee - Dutch Job Listing Platform", description: "Find your dream job in the Netherlands across all 12 provinces. Connect with top employers and build your career."};
|
||||||
"Discover thousands of job opportunities across all 12 Dutch provinces. Connect with top employers and build your career with Jobee."};
|
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
@@ -22,18 +17,31 @@ export default function RootLayout({
|
|||||||
<body className={`${inter.variable}`}>
|
<body className={`${inter.variable}`}>
|
||||||
{children}
|
{children}
|
||||||
<script
|
<script
|
||||||
|
id="lenis-setup"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `
|
__html: `
|
||||||
(function() {
|
window.lenis = null;
|
||||||
try {
|
if (typeof window !== 'undefined' && window.requestAnimationFrame) {
|
||||||
const theme = localStorage.getItem('theme') || 'light';
|
import('https://cdn.jsdelivr.net/npm/@studio-freight/lenis@1').then(({ default: Lenis }) => {
|
||||||
if (theme === 'dark') {
|
if (window.lenis) return;
|
||||||
document.documentElement.classList.add('dark');
|
window.lenis = new Lenis({
|
||||||
} else {
|
duration: 1.2,
|
||||||
document.documentElement.classList.remove('dark');
|
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||||
|
direction: 'vertical',
|
||||||
|
gestureDirection: 'vertical',
|
||||||
|
smooth: true,
|
||||||
|
mouseMultiplier: 1,
|
||||||
|
smoothTouch: false,
|
||||||
|
touchInertiaMultiplier: 2,
|
||||||
|
infinite: false,
|
||||||
|
});
|
||||||
|
function raf(time) {
|
||||||
|
if (window.lenis) window.lenis.raf(time);
|
||||||
|
requestAnimationFrame(raf);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(raf);
|
||||||
|
}).catch(err => console.log('Lenis load error', err));
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
|
||||||
})()
|
|
||||||
`,
|
`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Briefcase, Sparkles, Mail, Quote } from "lucide-react";
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: "Search Jobs", id: "search" },
|
{ name: "Search Jobs", id: "search" },
|
||||||
{ name: "Post a Job", id: "post-job" },
|
{ name: "Post a Job", id: "post-job" },
|
||||||
{ name: "Admin", id: "admin-login" },
|
{ name: "Admin", id: "/admin" },
|
||||||
{ name: "Browse", id: "browse" },
|
{ name: "Browse", id: "browse" },
|
||||||
{ name: "Contact", id: "contact" },
|
{ name: "Contact", id: "contact" },
|
||||||
];
|
];
|
||||||
@@ -63,7 +63,8 @@ export default function HomePage() {
|
|||||||
brandName="Jobee"
|
brandName="Jobee"
|
||||||
navItems={navItems}
|
navItems={navItems}
|
||||||
button={{
|
button={{
|
||||||
text: "Post a Job", href: "/post-job"}}
|
text: "Post a Job", href: "/post-job"
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -75,40 +76,43 @@ export default function HomePage() {
|
|||||||
tagIcon={Briefcase}
|
tagIcon={Briefcase}
|
||||||
tagAnimation="slide-up"
|
tagAnimation="slide-up"
|
||||||
background={{
|
background={{
|
||||||
variant: "animated-grid"}}
|
variant: "animated-grid"
|
||||||
|
}}
|
||||||
leftCarouselItems={[
|
leftCarouselItems={[
|
||||||
{
|
{
|
||||||
imageSrc:
|
imageSrc: "http://img.b2bpic.net/free-vector/professional-bookkeeping-postcard-template_23-2149341358.jpg?_wi=1", imageAlt: "Job Listing Card Design"
|
||||||
"http://img.b2bpic.net/free-vector/professional-bookkeeping-postcard-template_23-2149341358.jpg?_wi=1", imageAlt: "Job Listing Card Design"},
|
},
|
||||||
{
|
{
|
||||||
imageSrc:
|
imageSrc: "http://img.b2bpic.net/free-photo/homepage-concept-with-search-bar_23-2150040187.jpg?_wi=1", imageAlt: "Advanced Search Filter Interface"
|
||||||
"http://img.b2bpic.net/free-photo/homepage-concept-with-search-bar_23-2150040187.jpg?_wi=1", imageAlt: "Advanced Search Filter Interface"},
|
},
|
||||||
{
|
{
|
||||||
imageSrc:
|
imageSrc: "http://img.b2bpic.net/free-vector/professional-recruitment-plan-diversity-general-infographic-template_23-2148947635.jpg?_wi=1", imageAlt: "Admin Dashboard Management"
|
||||||
"http://img.b2bpic.net/free-vector/professional-recruitment-plan-diversity-general-infographic-template_23-2148947635.jpg?_wi=1", imageAlt: "Admin Dashboard Management"},
|
},
|
||||||
{
|
{
|
||||||
imageSrc:
|
imageSrc: "http://img.b2bpic.net/free-photo/personal-information-form-identity-concept_53876-137622.jpg?_wi=1", imageAlt: "Application Process Flow"
|
||||||
"http://img.b2bpic.net/free-photo/personal-information-form-identity-concept_53876-137622.jpg?_wi=1", imageAlt: "Application Process Flow"},
|
},
|
||||||
]}
|
]}
|
||||||
rightCarouselItems={[
|
rightCarouselItems={[
|
||||||
{
|
{
|
||||||
imageSrc:
|
imageSrc: "http://img.b2bpic.net/free-photo/corporate-workers-brainstorming-together_23-2148804568.jpg?_wi=1", imageAlt: "Professional Job Search Workspace"
|
||||||
"http://img.b2bpic.net/free-photo/corporate-workers-brainstorming-together_23-2148804568.jpg?_wi=1", imageAlt: "Professional Job Search Workspace"},
|
},
|
||||||
{
|
{
|
||||||
imageSrc:
|
imageSrc: "http://img.b2bpic.net/free-photo/personal-information-form-identity-concept_53876-137622.jpg?_wi=2", imageAlt: "Recruitment Application Steps"
|
||||||
"http://img.b2bpic.net/free-photo/personal-information-form-identity-concept_53876-137622.jpg?_wi=2", imageAlt: "Recruitment Application Steps"},
|
},
|
||||||
{
|
{
|
||||||
imageSrc:
|
imageSrc: "http://img.b2bpic.net/free-vector/professional-recruitment-plan-diversity-general-infographic-template_23-2148947635.jpg?_wi=2", imageAlt: "Recruitment Analytics Dashboard"
|
||||||
"http://img.b2bpic.net/free-vector/professional-recruitment-plan-diversity-general-infographic-template_23-2148947635.jpg?_wi=2", imageAlt: "Recruitment Analytics Dashboard"},
|
},
|
||||||
{
|
{
|
||||||
imageSrc:
|
imageSrc: "http://img.b2bpic.net/free-photo/homepage-concept-with-search-bar_23-2150040187.jpg?_wi=2", imageAlt: "Job Search Filter Options"
|
||||||
"http://img.b2bpic.net/free-photo/homepage-concept-with-search-bar_23-2150040187.jpg?_wi=2", imageAlt: "Job Search Filter Options"},
|
},
|
||||||
]}
|
]}
|
||||||
buttons={[
|
buttons={[
|
||||||
{
|
{
|
||||||
text: "Start Searching", href: "/search"},
|
text: "Start Searching", href: "/search"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: "Post a Job", href: "/post-job"},
|
text: "Post a Job", href: "/post-job"
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
buttonAnimation="slide-up"
|
buttonAnimation="slide-up"
|
||||||
carouselPosition="right"
|
carouselPosition="right"
|
||||||
@@ -127,28 +131,25 @@ export default function HomePage() {
|
|||||||
features={[
|
features={[
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
title: "Search & Filter", description:
|
title: "Search & Filter", description: "Browse thousands of jobs across all Dutch provinces with advanced filtering by location, salary, job type, and category.", imageSrc: "http://img.b2bpic.net/free-photo/homepage-concept-with-search-bar_23-2150040187.jpg?_wi=3", imageAlt: "Advanced search interface"
|
||||||
"Browse thousands of jobs across all Dutch provinces with advanced filtering by location, salary, job type, and category.", imageSrc:
|
},
|
||||||
"http://img.b2bpic.net/free-photo/homepage-concept-with-search-bar_23-2150040187.jpg?_wi=3", imageAlt: "Advanced search interface"},
|
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
title: "Apply Easily", description:
|
title: "Apply Easily", description: "Submit your application with your resume, cover letter, and personal information in just minutes.", imageSrc: "http://img.b2bpic.net/free-photo/personal-information-form-identity-concept_53876-137622.jpg?_wi=3", imageAlt: "Application form process"
|
||||||
"Submit your application with your resume, cover letter, and personal information in just minutes.", imageSrc:
|
},
|
||||||
"http://img.b2bpic.net/free-photo/personal-information-form-identity-concept_53876-137622.jpg?_wi=3", imageAlt: "Application form process"},
|
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
title: "Connect with Employers", description:
|
title: "Connect with Employers", description: "Get matched with top Dutch companies actively hiring and receive direct opportunities from recruiters.", imageSrc: "http://img.b2bpic.net/free-photo/corporate-workers-brainstorming-together_23-2148804568.jpg?_wi=2", imageAlt: "Professional networking"
|
||||||
"Get matched with top Dutch companies actively hiring and receive direct opportunities from recruiters.", imageSrc:
|
},
|
||||||
"http://img.b2bpic.net/free-photo/corporate-workers-brainstorming-together_23-2148804568.jpg?_wi=2", imageAlt: "Professional networking"},
|
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
title: "Land Your Dream Job", description:
|
title: "Land Your Dream Job", description: "Track your applications, receive updates, and secure your next career opportunity with confidence.", imageSrc: "http://img.b2bpic.net/free-vector/professional-recruitment-plan-diversity-general-infographic-template_23-2148947635.jpg?_wi=3", imageAlt: "Dashboard tracking"
|
||||||
"Track your applications, receive updates, and secure your next career opportunity with confidence.", imageSrc:
|
},
|
||||||
"http://img.b2bpic.net/free-vector/professional-recruitment-plan-diversity-general-infographic-template_23-2148947635.jpg?_wi=3", imageAlt: "Dashboard tracking"},
|
|
||||||
]}
|
]}
|
||||||
buttons={[
|
buttons={[
|
||||||
{
|
{
|
||||||
text: "Browse Jobs", href: "/search"},
|
text: "Browse Jobs", href: "/search"
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
buttonAnimation="slide-up"
|
buttonAnimation="slide-up"
|
||||||
/>
|
/>
|
||||||
@@ -166,21 +167,17 @@ export default function HomePage() {
|
|||||||
animationType="slide-up"
|
animationType="slide-up"
|
||||||
testimonials={[
|
testimonials={[
|
||||||
{
|
{
|
||||||
id: "1", name: "Sarah van der Berg", role: "Software Developer", testimonial:
|
id: "1", name: "Sarah van der Berg", role: "Software Developer", testimonial: "I found my perfect job within 2 weeks using Jobee. The search filters made it easy to find remote opportunities in Amsterdam that matched my skill set.", imageSrc: "http://img.b2bpic.net/free-photo/front-view-smiley-man-posing_23-2150171293.jpg?_wi=1", imageAlt: "Sarah van der Berg"
|
||||||
"I found my perfect job within 2 weeks using Jobee. The search filters made it easy to find remote opportunities in Amsterdam that matched my skill set.", imageSrc:
|
},
|
||||||
"http://img.b2bpic.net/free-photo/front-view-smiley-man-posing_23-2150171293.jpg?_wi=1", imageAlt: "Sarah van der Berg"},
|
|
||||||
{
|
{
|
||||||
id: "2", name: "Jan Pieterzoon", role: "HR Manager", testimonial:
|
id: "2", name: "Jan Pieterzoon", role: "HR Manager", testimonial: "As a recruiter, Jobee has revolutionized how we post jobs and connect with talented candidates across the Netherlands. The platform is intuitive and effective.", imageSrc: "http://img.b2bpic.net/free-photo/smiling-face-gorgeous-latin-american-woman_1262-5766.jpg?_wi=1", imageAlt: "Jan Pieterzoon"
|
||||||
"As a recruiter, Jobee has revolutionized how we post jobs and connect with talented candidates across the Netherlands. The platform is intuitive and effective.", imageSrc:
|
},
|
||||||
"http://img.b2bpic.net/free-photo/smiling-face-gorgeous-latin-american-woman_1262-5766.jpg?_wi=1", imageAlt: "Jan Pieterzoon"},
|
|
||||||
{
|
{
|
||||||
id: "3", name: "Emma Dijkstra", role: "Marketing Specialist", testimonial:
|
id: "3", name: "Emma Dijkstra", role: "Marketing Specialist", testimonial: "The application process on Jobee is so smooth. I applied for 5 positions and received 3 interview invitations within a week. Highly recommended!", imageSrc: "http://img.b2bpic.net/free-photo/smiling-senior-businessman-pointing-with-finger_1262-3108.jpg?_wi=1", imageAlt: "Emma Dijkstra"
|
||||||
"The application process on Jobee is so smooth. I applied for 5 positions and received 3 interview invitations within a week. Highly recommended!", imageSrc:
|
},
|
||||||
"http://img.b2bpic.net/free-photo/smiling-senior-businessman-pointing-with-finger_1262-3108.jpg?_wi=1", imageAlt: "Emma Dijkstra"},
|
|
||||||
{
|
{
|
||||||
id: "4", name: "Michael Houtstra", role: "Finance Director", testimonial:
|
id: "4", name: "Michael Houtstra", role: "Finance Director", testimonial: "We've hired 12 excellent team members through Jobee in the past year. The quality of candidates is outstanding and the platform is incredibly easy to use.", imageSrc: "http://img.b2bpic.net/free-photo/studio-portrait-elegant-black-american-male-dressed-suit-grey-vignette-background_613910-9543.jpg?_wi=1", imageAlt: "Michael Houtstra"
|
||||||
"We've hired 12 excellent team members through Jobee in the past year. The quality of candidates is outstanding and the platform is incredibly easy to use.", imageSrc:
|
},
|
||||||
"http://img.b2bpic.net/free-photo/studio-portrait-elegant-black-american-male-dressed-suit-grey-vignette-background_613910-9543.jpg?_wi=1", imageAlt: "Michael Houtstra"},
|
|
||||||
]}
|
]}
|
||||||
buttonAnimation="slide-up"
|
buttonAnimation="slide-up"
|
||||||
/>
|
/>
|
||||||
@@ -194,7 +191,8 @@ export default function HomePage() {
|
|||||||
tagIcon={Mail}
|
tagIcon={Mail}
|
||||||
tagAnimation="slide-up"
|
tagAnimation="slide-up"
|
||||||
background={{
|
background={{
|
||||||
variant: "animated-grid"}}
|
variant: "animated-grid"
|
||||||
|
}}
|
||||||
useInvertedBackground={false}
|
useInvertedBackground={false}
|
||||||
inputPlaceholder="Enter your email address"
|
inputPlaceholder="Enter your email address"
|
||||||
buttonText="Subscribe"
|
buttonText="Subscribe"
|
||||||
|
|||||||
@@ -2,16 +2,14 @@
|
|||||||
|
|
||||||
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
||||||
import NavbarStyleCentered from "@/components/navbar/NavbarStyleCentered/NavbarStyleCentered";
|
import NavbarStyleCentered from "@/components/navbar/NavbarStyleCentered/NavbarStyleCentered";
|
||||||
import FeatureCardEight from "@/components/sections/feature/FeatureCardEight";
|
|
||||||
import ContactCenter from "@/components/sections/contact/ContactCenter";
|
|
||||||
import FooterBase from "@/components/sections/footer/FooterBase";
|
import FooterBase from "@/components/sections/footer/FooterBase";
|
||||||
import { Sparkles, Mail } from "lucide-react";
|
import { Briefcase, Mail } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
export default function PostJobPage() {
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: "Search Jobs", id: "search" },
|
{ name: "Search Jobs", id: "search" },
|
||||||
{ name: "Post a Job", id: "post-job" },
|
{ name: "Post a Job", id: "/post-job" },
|
||||||
{ name: "Admin", id: "admin-login" },
|
{ name: "Applications", id: "/applications" },
|
||||||
{ name: "Browse", id: "browse" },
|
{ name: "Browse", id: "browse" },
|
||||||
{ name: "Contact", id: "contact" },
|
{ name: "Contact", id: "contact" },
|
||||||
];
|
];
|
||||||
@@ -21,7 +19,7 @@ export default function PostJobPage() {
|
|||||||
title: "Product", items: [
|
title: "Product", items: [
|
||||||
{ label: "Search Jobs", href: "/search" },
|
{ label: "Search Jobs", href: "/search" },
|
||||||
{ label: "Post a Job", href: "/post-job" },
|
{ label: "Post a Job", href: "/post-job" },
|
||||||
{ label: "Browse by Province", href: "#provinces" },
|
{ label: "My Applications", href: "/applications" },
|
||||||
{ label: "For Employers", href: "#" },
|
{ label: "For Employers", href: "#" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -43,6 +41,32 @@ export default function PostJobPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export default function PostJobPage() {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
jobTitle: "", company: "", location: "", province: "", jobType: "Full-Time", salaryRange: "", description: "", requirements: "", contactEmail: "", benefits: ""});
|
||||||
|
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = (
|
||||||
|
e: React.ChangeEvent<
|
||||||
|
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
console.log("Job Posted:", formData);
|
||||||
|
setSubmitted(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setFormData({
|
||||||
|
jobTitle: "", company: "", location: "", province: "", jobType: "Full-Time", salaryRange: "", description: "", requirements: "", contactEmail: "", benefits: ""});
|
||||||
|
setSubmitted(false);
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
defaultButtonVariant="text-stagger"
|
defaultButtonVariant="text-stagger"
|
||||||
@@ -58,63 +82,263 @@ export default function PostJobPage() {
|
|||||||
>
|
>
|
||||||
<div id="nav" data-section="nav">
|
<div id="nav" data-section="nav">
|
||||||
<NavbarStyleCentered
|
<NavbarStyleCentered
|
||||||
navItems={navItems}
|
|
||||||
button={{ text: "Post a Job", href: "/post-job" }}
|
|
||||||
brandName="Jobee"
|
brandName="Jobee"
|
||||||
|
navItems={navItems}
|
||||||
|
button={{
|
||||||
|
text: "Post a Job", href: "/post-job"}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="features" data-section="features">
|
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-slate-100 pt-32 pb-20">
|
||||||
<FeatureCardEight
|
<div className="max-w-2xl mx-auto px-4">
|
||||||
features={[
|
<div className="mb-8">
|
||||||
{
|
<div className="flex items-center gap-3 mb-4">
|
||||||
id: 1,
|
<Briefcase className="w-6 h-6 text-blue-600" />
|
||||||
title: "Create a Job Posting", description:
|
<span className="text-sm font-semibold text-blue-600 uppercase tracking-wide">
|
||||||
"Fill in job details, requirements, and salary information. Our intuitive form guides you through every step to create a compelling job listing.", imageSrc:
|
Post Your Job
|
||||||
"http://img.b2bpic.net/free-vector/professional-recruitment-plan-diversity-general-infographic-template_23-2148947635.jpg?_wi=4", imageAlt: "Job posting creation interface"},
|
</span>
|
||||||
{
|
</div>
|
||||||
id: 2,
|
<h1 className="text-4xl md:text-5xl font-bold text-slate-900 mb-3">
|
||||||
title: "Reach Qualified Candidates", description:
|
Create Your Job Listing
|
||||||
"Your job posting is automatically distributed across our network of active job seekers across all 12 Dutch provinces.", imageSrc:
|
</h1>
|
||||||
"http://img.b2bpic.net/free-photo/homepage-concept-with-search-bar_23-2150040187.jpg?_wi=5", imageAlt: "Candidate reach visualization"},
|
<p className="text-lg text-slate-600">
|
||||||
{
|
Fill in the details below to post your job opening and reach qualified candidates
|
||||||
id: 3,
|
across the Netherlands.
|
||||||
title: "Review Applications", description:
|
</p>
|
||||||
"Manage all incoming applications in one centralized dashboard. Review resumes, cover letters, and candidate profiles easily.", imageSrc:
|
</div>
|
||||||
"http://img.b2bpic.net/free-photo/personal-information-form-identity-concept_53876-137622.jpg?_wi=4", imageAlt: "Application review dashboard"},
|
|
||||||
]}
|
{submitted && (
|
||||||
title="Post a Job in Three Easy Steps"
|
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||||
description="Reach thousands of qualified job seekers across the Netherlands and build your dream team."
|
<p className="text-green-800 font-semibold">✓ Job posted successfully!</p>
|
||||||
tag="Simple Process"
|
</div>
|
||||||
tagIcon={Sparkles}
|
)}
|
||||||
tagAnimation="slide-up"
|
|
||||||
textboxLayout="default"
|
<form
|
||||||
useInvertedBackground={false}
|
onSubmit={handleSubmit}
|
||||||
buttons={[{ text: "Start Posting", href: "/post-job" }]}
|
className="bg-white rounded-lg shadow-lg p-8 space-y-6"
|
||||||
buttonAnimation="slide-up"
|
>
|
||||||
|
{/* Job Title */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="jobTitle" className="block text-sm font-semibold text-slate-900 mb-2">
|
||||||
|
Job Title *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="jobTitle"
|
||||||
|
name="jobTitle"
|
||||||
|
value={formData.jobTitle}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
placeholder="e.g., Senior Software Developer"
|
||||||
|
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="contact" data-section="contact">
|
{/* Company */}
|
||||||
<ContactCenter
|
<div>
|
||||||
tag="Newsletter"
|
<label htmlFor="company" className="block text-sm font-semibold text-slate-900 mb-2">
|
||||||
title="Get Notified About Top Talent"
|
Company Name *
|
||||||
description="Subscribe to receive alerts when qualified candidates match your job requirements. Stay ahead of the competition and hire the best talent."
|
</label>
|
||||||
tagIcon={Mail}
|
<input
|
||||||
tagAnimation="slide-up"
|
type="text"
|
||||||
background={{ variant: "animated-grid" }}
|
id="company"
|
||||||
useInvertedBackground={false}
|
name="company"
|
||||||
inputPlaceholder="Enter your company email"
|
value={formData.company}
|
||||||
buttonText="Subscribe"
|
onChange={handleChange}
|
||||||
termsText="We respect your privacy. Unsubscribe anytime from our notifications."
|
required
|
||||||
|
placeholder="Your company name"
|
||||||
|
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="location" className="block text-sm font-semibold text-slate-900 mb-2">
|
||||||
|
City *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="location"
|
||||||
|
name="location"
|
||||||
|
value={formData.location}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
placeholder="e.g., Amsterdam"
|
||||||
|
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="province" className="block text-sm font-semibold text-slate-900 mb-2">
|
||||||
|
Province *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="province"
|
||||||
|
name="province"
|
||||||
|
value={formData.province}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="">Select Province</option>
|
||||||
|
<option value="North Holland">North Holland</option>
|
||||||
|
<option value="South Holland">South Holland</option>
|
||||||
|
<option value="Utrecht">Utrecht</option>
|
||||||
|
<option value="Gelderland">Gelderland</option>
|
||||||
|
<option value="North Brabant">North Brabant</option>
|
||||||
|
<option value="Overijssel">Overijssel</option>
|
||||||
|
<option value="Flevoland">Flevoland</option>
|
||||||
|
<option value="Friesland">Friesland</option>
|
||||||
|
<option value="Groningen">Groningen</option>
|
||||||
|
<option value="Drenthe">Drenthe</option>
|
||||||
|
<option value="Limburg">Limburg</option>
|
||||||
|
<option value="Zeeland">Zeeland</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Job Type */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="jobType" className="block text-sm font-semibold text-slate-900 mb-2">
|
||||||
|
Job Type *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="jobType"
|
||||||
|
name="jobType"
|
||||||
|
value={formData.jobType}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="Full-Time">Full-Time</option>
|
||||||
|
<option value="Part-Time">Part-Time</option>
|
||||||
|
<option value="Contract">Contract</option>
|
||||||
|
<option value="Temporary">Temporary</option>
|
||||||
|
<option value="Internship">Internship</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="salaryRange" className="block text-sm font-semibold text-slate-900 mb-2">
|
||||||
|
Salary Range
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="salaryRange"
|
||||||
|
name="salaryRange"
|
||||||
|
value={formData.salaryRange}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="e.g., €2,000 - €3,500"
|
||||||
|
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="description" className="block text-sm font-semibold text-slate-900 mb-2">
|
||||||
|
Job Description *
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
rows={4}
|
||||||
|
placeholder="Describe the role, responsibilities, and key tasks..."
|
||||||
|
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Requirements */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="requirements" className="block text-sm font-semibold text-slate-900 mb-2">
|
||||||
|
Requirements *
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="requirements"
|
||||||
|
name="requirements"
|
||||||
|
value={formData.requirements}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
rows={4}
|
||||||
|
placeholder="List the required skills, experience, education, and qualifications..."
|
||||||
|
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Benefits */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="benefits" className="block text-sm font-semibold text-slate-900 mb-2">
|
||||||
|
Benefits & Perks
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="benefits"
|
||||||
|
name="benefits"
|
||||||
|
value={formData.benefits}
|
||||||
|
onChange={handleChange}
|
||||||
|
rows={3}
|
||||||
|
placeholder="List benefits such as health insurance, remote work, stock options, etc..."
|
||||||
|
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Email */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="contactEmail" className="block text-sm font-semibold text-slate-900 mb-2">
|
||||||
|
Contact Email *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="contactEmail"
|
||||||
|
name="contactEmail"
|
||||||
|
value={formData.contactEmail}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
placeholder="recruiter@company.com"
|
||||||
|
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<div className="flex gap-4 pt-6">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 rounded-lg transition duration-200"
|
||||||
|
>
|
||||||
|
Post Job
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setFormData({
|
||||||
|
jobTitle: "", company: "", location: "", province: "", jobType: "Full-Time", salaryRange: "", description: "", requirements: "", contactEmail: "", benefits: ""})
|
||||||
|
}
|
||||||
|
className="flex-1 bg-slate-200 hover:bg-slate-300 text-slate-900 font-semibold py-3 rounded-lg transition duration-200"
|
||||||
|
>
|
||||||
|
Clear Form
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-8 p-6 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 mb-2">✓ Tips for a Great Job Listing</h3>
|
||||||
|
<ul className="text-slate-700 space-y-1">
|
||||||
|
<li>• Use clear, descriptive job titles</li>
|
||||||
|
<li>• Include specific requirements and qualifications</li>
|
||||||
|
<li>• Highlight company culture and benefits</li>
|
||||||
|
<li>• Provide accurate salary ranges</li>
|
||||||
|
<li>• Ensure contact information is correct</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="footer" data-section="footer">
|
<div id="footer" data-section="footer">
|
||||||
<FooterBase
|
<FooterBase
|
||||||
columns={footerColumns}
|
|
||||||
logoText="Jobee"
|
logoText="Jobee"
|
||||||
copyrightText="© 2025 Jobee | Dutch Job Listing Platform"
|
copyrightText="© 2025 Jobee | Dutch Job Listing Platform"
|
||||||
|
columns={footerColumns}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
||||||
import NavbarStyleCentered from "@/components/navbar/NavbarStyleCentered/NavbarStyleCentered";
|
import NavbarStyleCentered from "@/components/navbar/NavbarStyleCentered/NavbarStyleCentered";
|
||||||
import FeatureCardEight from "@/components/sections/feature/FeatureCardEight";
|
|
||||||
import ContactCenter from "@/components/sections/contact/ContactCenter";
|
|
||||||
import FooterBase from "@/components/sections/footer/FooterBase";
|
import FooterBase from "@/components/sections/footer/FooterBase";
|
||||||
import { Sparkles, Mail } from "lucide-react";
|
import { Search, ChevronDown, MapPin, DollarSign, Briefcase } from "lucide-react";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: "Search Jobs", id: "search" },
|
{ name: "Search Jobs", id: "/search" },
|
||||||
{ name: "Post a Job", id: "post-job" },
|
{ name: "Post a Job", id: "/post-job" },
|
||||||
{ name: "Admin", id: "admin-login" },
|
{ name: "Admin", id: "/admin-login" },
|
||||||
{ name: "Browse", id: "browse" },
|
{ name: "Browse", id: "browse" },
|
||||||
{ name: "Contact", id: "contact" },
|
{ name: "Contact", id: "contact" },
|
||||||
];
|
];
|
||||||
@@ -42,7 +41,115 @@ const footerColumns = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const mockJobs = [
|
||||||
|
{
|
||||||
|
id: "1", title: "Senior Software Engineer", company: "TechCorp Amsterdam", location: "Amsterdam", province: "North Holland", salary: "€80,000 - €120,000", salaryMin: 80000,
|
||||||
|
salaryMax: 120000,
|
||||||
|
jobType: "Full-time", category: "Technology", description:
|
||||||
|
"Join our innovative team as a Senior Software Engineer. We\'re looking for experienced developers with expertise in React, Node.js, and cloud technologies.", logo: "http://img.b2bpic.net/free-vector/tech-company-logo_23-2148947635.jpg"},
|
||||||
|
{
|
||||||
|
id: "2", title: "Marketing Manager", company: "Creative Solutions Rotterdam", location: "Rotterdam", province: "South Holland", salary: "€60,000 - €85,000", salaryMin: 60000,
|
||||||
|
salaryMax: 85000,
|
||||||
|
jobType: "Full-time", category: "Marketing", description:
|
||||||
|
"Lead our marketing initiatives and drive brand growth. We seek a strategic marketer with experience in digital marketing and campaign management.", logo: "http://img.b2bpic.net/free-vector/marketing-logo_23-2148947635.jpg"},
|
||||||
|
{
|
||||||
|
id: "3", title: "Data Scientist", company: "Analytics Pro Utrecht", location: "Utrecht", province: "Utrecht", salary: "€70,000 - €110,000", salaryMin: 70000,
|
||||||
|
salaryMax: 110000,
|
||||||
|
jobType: "Full-time", category: "Data Science", description:
|
||||||
|
"Develop advanced analytics solutions for our global clients. Expertise in Python, machine learning, and big data technologies required.", logo: "http://img.b2bpic.net/free-vector/analytics-logo_23-2148947635.jpg"},
|
||||||
|
{
|
||||||
|
id: "4", title: "UX/UI Designer", company: "Design Studios The Hague", location: "The Hague", province: "South Holland", salary: "€55,000 - €75,000", salaryMin: 55000,
|
||||||
|
salaryMax: 75000,
|
||||||
|
jobType: "Full-time", category: "Design", description:
|
||||||
|
"Create beautiful and intuitive user experiences for our web and mobile applications. Portfolio required.", logo: "http://img.b2bpic.net/free-vector/design-logo_23-2148947635.jpg"},
|
||||||
|
{
|
||||||
|
id: "5", title: "Sales Executive", company: "Enterprise Solutions Groningen", location: "Groningen", province: "Groningen", salary: "€45,000 - €70,000", salaryMin: 45000,
|
||||||
|
salaryMax: 70000,
|
||||||
|
jobType: "Full-time", category: "Sales", description:
|
||||||
|
"Grow our sales pipeline and build strong client relationships. Commission and bonus structure available.", logo: "http://img.b2bpic.net/free-vector/sales-logo_23-2148947635.jpg"},
|
||||||
|
{
|
||||||
|
id: "6", title: "HR Specialist", company: "People First Leiden", location: "Leiden", province: "South Holland", salary: "€50,000 - €65,000", salaryMin: 50000,
|
||||||
|
salaryMax: 65000,
|
||||||
|
jobType: "Part-time", category: "Human Resources", description:
|
||||||
|
"Support our HR team in recruitment, onboarding, and employee development initiatives.", logo: "http://img.b2bpic.net/free-vector/hr-logo_23-2148947635.jpg"},
|
||||||
|
{
|
||||||
|
id: "7", title: "DevOps Engineer", company: "Cloud Innovations Eindhoven", location: "Eindhoven", province: "North Brabant", salary: "€75,000 - €105,000", salaryMin: 75000,
|
||||||
|
salaryMax: 105000,
|
||||||
|
jobType: "Full-time", category: "Technology", description:
|
||||||
|
"Manage and optimize our cloud infrastructure. Experience with Docker, Kubernetes, and CI/CD pipelines required.", logo: "http://img.b2bpic.net/free-vector/devops-logo_23-2148947635.jpg"},
|
||||||
|
{
|
||||||
|
id: "8", title: "Product Manager", company: "Innovation Lab Delft", location: "Delft", province: "South Holland", salary: "€70,000 - €95,000", salaryMin: 70000,
|
||||||
|
salaryMax: 95000,
|
||||||
|
jobType: "Full-time", category: "Product", description:
|
||||||
|
"Lead product strategy and roadmap development for our SaaS platform. Experience with agile methodologies required.", logo: "http://img.b2bpic.net/free-vector/product-logo_23-2148947635.jpg"},
|
||||||
|
];
|
||||||
|
|
||||||
|
const provinces = [
|
||||||
|
"All Provinces", "North Holland", "South Holland", "Utrecht", "Groningen", "North Brabant", "Limburg", "Friesland", "Drenthe", "Flevoland", "Overijssel", "Gelderland"];
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
"All Categories", "Technology", "Marketing", "Sales", "Design", "Data Science", "Human Resources", "Product"];
|
||||||
|
|
||||||
|
const jobTypes = ["All Types", "Full-time", "Part-time", "Contract", "Freelance"];
|
||||||
|
|
||||||
export default function SearchPage() {
|
export default function SearchPage() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [selectedProvince, setSelectedProvince] = useState("All Provinces");
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState("All Categories");
|
||||||
|
const [selectedJobType, setSelectedJobType] = useState("All Types");
|
||||||
|
const [sortBy, setSortBy] = useState("relevant");
|
||||||
|
const [minSalary, setMinSalary] = useState(0);
|
||||||
|
const [maxSalary, setMaxSalary] = useState(150000);
|
||||||
|
|
||||||
|
const filteredJobs = useMemo(() => {
|
||||||
|
let filtered = mockJobs.filter((job) => {
|
||||||
|
const matchesSearch =
|
||||||
|
job.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
job.company.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
job.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
|
||||||
|
const matchesProvince =
|
||||||
|
selectedProvince === "All Provinces" ||
|
||||||
|
job.province === selectedProvince;
|
||||||
|
|
||||||
|
const matchesCategory =
|
||||||
|
selectedCategory === "All Categories" ||
|
||||||
|
job.category === selectedCategory;
|
||||||
|
|
||||||
|
const matchesJobType =
|
||||||
|
selectedJobType === "All Types" || job.jobType === selectedJobType;
|
||||||
|
|
||||||
|
const matchesSalary =
|
||||||
|
job.salaryMin >= minSalary && job.salaryMax <= maxSalary;
|
||||||
|
|
||||||
|
return (
|
||||||
|
matchesSearch &&
|
||||||
|
matchesProvince &&
|
||||||
|
matchesCategory &&
|
||||||
|
matchesJobType &&
|
||||||
|
matchesSalary
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sortBy === "salary-high") {
|
||||||
|
filtered.sort((a, b) => b.salaryMax - a.salaryMax);
|
||||||
|
} else if (sortBy === "salary-low") {
|
||||||
|
filtered.sort((a, b) => a.salaryMin - b.salaryMin);
|
||||||
|
} else if (sortBy === "recent") {
|
||||||
|
filtered.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [
|
||||||
|
searchQuery,
|
||||||
|
selectedProvince,
|
||||||
|
selectedCategory,
|
||||||
|
selectedJobType,
|
||||||
|
minSalary,
|
||||||
|
maxSalary,
|
||||||
|
sortBy,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
defaultButtonVariant="text-stagger"
|
defaultButtonVariant="text-stagger"
|
||||||
@@ -65,56 +172,190 @@ export default function SearchPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="features" data-section="features">
|
<div className="min-h-screen bg-gradient-to-b from-background to-background-accent pt-20 pb-20">
|
||||||
<FeatureCardEight
|
<div className="max-w-7xl mx-auto px-4">
|
||||||
title="Search & Browse Jobs Across the Netherlands"
|
{/* Search Header */}
|
||||||
description="Explore our comprehensive job search experience with advanced filters, diverse listings, and tailored opportunities for every career stage."
|
<div className="mb-12">
|
||||||
tag="Advanced Search"
|
<h1 className="text-4xl md:text-5xl font-bold text-foreground mb-4">
|
||||||
tagIcon={Sparkles}
|
Find Your Dream Job
|
||||||
tagAnimation="slide-up"
|
</h1>
|
||||||
textboxLayout="default"
|
<p className="text-lg text-foreground/70 mb-8">
|
||||||
useInvertedBackground={false}
|
Discover thousands of opportunities across the Netherlands
|
||||||
features={[
|
</p>
|
||||||
{
|
|
||||||
id: 1,
|
{/* Main Search Bar */}
|
||||||
title: "Filter by Province", description:
|
<div className="relative mb-8">
|
||||||
"Search jobs from all 12 Dutch provinces including Amsterdam, Rotterdam, Utrecht, and beyond. Find opportunities near you or explore remote positions nationwide.", imageSrc:
|
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 text-foreground/50" />
|
||||||
"http://img.b2bpic.net/free-photo/homepage-concept-with-search-bar_23-2150040187.jpg?_wi=4", imageAlt: "Province filter interface"},
|
<input
|
||||||
{
|
type="text"
|
||||||
id: 2,
|
placeholder="Search by job title, company, or keywords..."
|
||||||
title: "Refine by Category", description:
|
value={searchQuery}
|
||||||
"Browse across multiple industries: Technology, Healthcare, Finance, Engineering, Marketing, Sales, and more. Find roles that match your expertise and interests.", imageSrc:
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
"http://img.b2bpic.net/free-vector/professional-bookkeeping-postcard-template_23-2149341358.jpg?_wi=2", imageAlt: "Job category options"},
|
className="w-full pl-12 pr-4 py-4 rounded-full bg-card border border-accent/20 text-foreground placeholder-foreground/50 focus:outline-none focus:ring-2 focus:ring-primary-cta"
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: "Salary & Experience Level", description:
|
|
||||||
"Filter by salary range, job type (full-time, part-time, contract), and experience level (entry-level, mid-career, senior) to find the perfect match for your career goals.", imageSrc:
|
|
||||||
"http://img.b2bpic.net/free-photo/corporate-workers-brainstorming-together_23-2148804568.jpg?_wi=3", imageAlt: "Salary and level filters"},
|
|
||||||
]}
|
|
||||||
buttons={[
|
|
||||||
{
|
|
||||||
text: "Start Your Search", href: "/search"},
|
|
||||||
]}
|
|
||||||
buttonAnimation="slide-up"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="contact" data-section="contact">
|
{/* Filter Bar */}
|
||||||
<ContactCenter
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
|
||||||
tag="Save Your Search"
|
{/* Province Filter */}
|
||||||
title="Get Personalized Job Recommendations"
|
<div className="relative">
|
||||||
description="Create an account and set up job alerts. We'll notify you about new positions matching your criteria so you never miss an opportunity."
|
<select
|
||||||
tagIcon={Mail}
|
value={selectedProvince}
|
||||||
tagAnimation="slide-up"
|
onChange={(e) => setSelectedProvince(e.target.value)}
|
||||||
background={{
|
className="w-full px-4 py-3 rounded-lg bg-card border border-accent/20 text-foreground cursor-pointer appearance-none pr-10 focus:outline-none focus:ring-2 focus:ring-primary-cta"
|
||||||
variant: "animated-grid"}}
|
>
|
||||||
useInvertedBackground={false}
|
{provinces.map((province) => (
|
||||||
inputPlaceholder="Enter your email to get started"
|
<option key={province} value={province}>
|
||||||
buttonText="Create Alert"
|
{province}
|
||||||
termsText="Your preferences are private and secure. Manage your alerts anytime."
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<MapPin className="absolute right-3 top-1/2 transform -translate-y-1/2 text-foreground/50 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Filter */}
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-card border border-accent/20 text-foreground cursor-pointer appearance-none pr-10 focus:outline-none focus:ring-2 focus:ring-primary-cta"
|
||||||
|
>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<option key={category} value={category}>
|
||||||
|
{category}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Briefcase className="absolute right-3 top-1/2 transform -translate-y-1/2 text-foreground/50 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Job Type Filter */}
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={selectedJobType}
|
||||||
|
onChange={(e) => setSelectedJobType(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-card border border-accent/20 text-foreground cursor-pointer appearance-none pr-10 focus:outline-none focus:ring-2 focus:ring-primary-cta"
|
||||||
|
>
|
||||||
|
{jobTypes.map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{type}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-3 top-1/2 transform -translate-y-1/2 text-foreground/50 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Salary Range */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DollarSign className="text-foreground/50" />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="150000"
|
||||||
|
step="10000"
|
||||||
|
value={maxSalary}
|
||||||
|
onChange={(e) => setMaxSalary(parseInt(e.target.value))}
|
||||||
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sort By */}
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 rounded-lg bg-card border border-accent/20 text-foreground cursor-pointer appearance-none pr-10 focus:outline-none focus:ring-2 focus:ring-primary-cta"
|
||||||
|
>
|
||||||
|
<option value="relevant">Most Relevant</option>
|
||||||
|
<option value="recent">Most Recent</option>
|
||||||
|
<option value="salary-high">Highest Salary</option>
|
||||||
|
<option value="salary-low">Lowest Salary</option>
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-3 top-1/2 transform -translate-y-1/2 text-foreground/50 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results Count */}
|
||||||
|
<p className="text-sm text-foreground/60">
|
||||||
|
Showing {filteredJobs.length} of {mockJobs.length} jobs
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Job Listings */}
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{filteredJobs.length > 0 ? (
|
||||||
|
filteredJobs.map((job) => (
|
||||||
|
<div
|
||||||
|
key={job.id}
|
||||||
|
className="p-6 rounded-xl bg-card border border-accent/20 hover:border-accent/50 transition-all duration-300 hover:shadow-lg cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-start gap-4 flex-1">
|
||||||
|
<div className="w-16 h-16 rounded-lg bg-gradient-to-br from-primary-cta to-secondary-cta flex items-center justify-center text-white font-bold text-xl flex-shrink-0">
|
||||||
|
{job.company.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-xl font-bold text-foreground mb-1">
|
||||||
|
{job.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-foreground/70 mb-2">
|
||||||
|
{job.company}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-foreground/60">
|
||||||
|
{job.location}, {job.province}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-lg font-bold text-primary-cta mb-2">
|
||||||
|
{job.salary}
|
||||||
|
</p>
|
||||||
|
<span className="inline-block px-3 py-1 rounded-full text-xs font-semibold bg-primary-cta/10 text-primary-cta">
|
||||||
|
{job.jobType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-foreground/70 mb-4 line-clamp-2">
|
||||||
|
{job.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="inline-block px-3 py-1 rounded-lg text-xs bg-background text-foreground/70">
|
||||||
|
{job.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 rounded-full bg-primary-cta text-white font-semibold hover:opacity-90 transition-opacity">
|
||||||
|
View Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-lg text-foreground/60 mb-4">
|
||||||
|
No jobs found matching your criteria.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery("");
|
||||||
|
setSelectedProvince("All Provinces");
|
||||||
|
setSelectedCategory("All Categories");
|
||||||
|
setSelectedJobType("All Types");
|
||||||
|
setMinSalary(0);
|
||||||
|
setMaxSalary(150000);
|
||||||
|
}}
|
||||||
|
className="px-6 py-2 rounded-full bg-primary-cta text-white font-semibold hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
Clear Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="footer" data-section="footer">
|
<div id="footer" data-section="footer">
|
||||||
<FooterBase
|
<FooterBase
|
||||||
logoText="Jobee"
|
logoText="Jobee"
|
||||||
|
|||||||
@@ -10,15 +10,15 @@
|
|||||||
--accent: #ffffff;
|
--accent: #ffffff;
|
||||||
--background-accent: #ffffff; */
|
--background-accent: #ffffff; */
|
||||||
|
|
||||||
--background: #f5f5f5;
|
--background: #ffffff;
|
||||||
--card: #ffffff;
|
--card: #f9f9f9;
|
||||||
--foreground: #1c1c1c;
|
--foreground: #000612e6;
|
||||||
--primary-cta: #1c1c1c;
|
--primary-cta: #15479c;
|
||||||
--primary-cta-text: #f5f5f5;
|
--primary-cta-text: #f5f5f5;
|
||||||
--secondary-cta: #ffffff;
|
--secondary-cta: #f9f9f9;
|
||||||
--secondary-cta-text: #1c1c1c;
|
--secondary-cta-text: #1c1c1c;
|
||||||
--accent: #15479c;
|
--accent: #e2e2e2;
|
||||||
--background-accent: #a8cce8;
|
--background-accent: #c4c4c4;
|
||||||
|
|
||||||
/* text sizing - set by ThemeProvider */
|
/* text sizing - set by ThemeProvider */
|
||||||
/* --text-2xs: clamp(0.465rem, 0.62vw, 0.62rem);
|
/* --text-2xs: clamp(0.465rem, 0.62vw, 0.62rem);
|
||||||
|
|||||||
@@ -1,229 +1,69 @@
|
|||||||
"use client";
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { memo, Children } from "react";
|
interface TimelineBaseProps {
|
||||||
import { CardStackProps } from "./types";
|
items: Array<{
|
||||||
import GridLayout from "./layouts/grid/GridLayout";
|
id: string | number;
|
||||||
import AutoCarousel from "./layouts/carousels/AutoCarousel";
|
content: React.ReactNode;
|
||||||
import ButtonCarousel from "./layouts/carousels/ButtonCarousel";
|
media: React.ReactNode;
|
||||||
import TimelineBase from "./layouts/timelines/TimelineBase";
|
reverse?: boolean;
|
||||||
import { gridConfigs } from "./layouts/grid/gridConfigs";
|
}>;
|
||||||
|
reverse?: boolean;
|
||||||
const CardStack = ({
|
className?: string;
|
||||||
children,
|
containerClassName?: string;
|
||||||
mode = "buttons",
|
itemClassName?: string;
|
||||||
gridVariant = "uniform-all-items-equal",
|
mediaWrapperClassName?: string;
|
||||||
uniformGridCustomHeightClasses,
|
numberClassName?: string;
|
||||||
gridRowsClassName,
|
contentWrapperClassName?: string;
|
||||||
itemHeightClassesOverride,
|
gapClassName?: string;
|
||||||
animationType,
|
ariaLabel?: string;
|
||||||
supports3DAnimation = false,
|
|
||||||
title,
|
|
||||||
titleSegments,
|
|
||||||
description,
|
|
||||||
tag,
|
|
||||||
tagIcon,
|
|
||||||
tagAnimation,
|
|
||||||
buttons,
|
|
||||||
buttonAnimation,
|
|
||||||
textboxLayout = "default",
|
|
||||||
useInvertedBackground,
|
|
||||||
carouselThreshold = 5,
|
|
||||||
bottomContent,
|
|
||||||
className = "",
|
|
||||||
containerClassName = "",
|
|
||||||
gridClassName = "",
|
|
||||||
carouselClassName = "",
|
|
||||||
carouselItemClassName = "",
|
|
||||||
controlsClassName = "",
|
|
||||||
textBoxClassName = "",
|
|
||||||
titleClassName = "",
|
|
||||||
titleImageWrapperClassName = "",
|
|
||||||
titleImageClassName = "",
|
|
||||||
descriptionClassName = "",
|
|
||||||
tagClassName = "",
|
|
||||||
buttonContainerClassName = "",
|
|
||||||
buttonClassName = "",
|
|
||||||
buttonTextClassName = "",
|
|
||||||
ariaLabel = "Card stack",
|
|
||||||
}: CardStackProps) => {
|
|
||||||
const childrenArray = Children.toArray(children);
|
|
||||||
const itemCount = childrenArray.length;
|
|
||||||
|
|
||||||
// Check if the current grid config has gridRows defined
|
|
||||||
const gridConfig = gridConfigs[gridVariant]?.[itemCount];
|
|
||||||
const hasFixedGridRows = gridConfig && 'gridRows' in gridConfig && gridConfig.gridRows;
|
|
||||||
|
|
||||||
// If grid has fixed row heights and we have uniformGridCustomHeightClasses,
|
|
||||||
// we need to use min-h-0 on md+ to prevent conflicts
|
|
||||||
let adjustedHeightClasses = uniformGridCustomHeightClasses;
|
|
||||||
if (hasFixedGridRows && uniformGridCustomHeightClasses) {
|
|
||||||
// Extract the mobile min-height and add md:min-h-0
|
|
||||||
const mobileMinHeight = uniformGridCustomHeightClasses.split(' ')[0];
|
|
||||||
adjustedHeightClasses = `${mobileMinHeight} md:min-h-0`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timeline layout for zigzag pattern (works best with 3-6 items)
|
const CardStack = React.forwardRef<HTMLDivElement, TimelineBaseProps>(
|
||||||
if (gridVariant === "timeline" && itemCount >= 3 && itemCount <= 6) {
|
(
|
||||||
// Convert depth-3d to scale-rotate for timeline (doesn't support 3D)
|
{
|
||||||
const timelineAnimationType = animationType === "depth-3d" ? "scale-rotate" : animationType;
|
items,
|
||||||
|
reverse = false,
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
itemClassName,
|
||||||
|
mediaWrapperClassName,
|
||||||
|
numberClassName,
|
||||||
|
contentWrapperClassName,
|
||||||
|
gapClassName,
|
||||||
|
ariaLabel = 'Card stack timeline',
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [windowHeight, setWindowHeight] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setWindowHeight(typeof window !== 'undefined' ? window.innerHeight : 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TimelineBase
|
<div
|
||||||
variant={gridVariant}
|
ref={ref}
|
||||||
uniformGridCustomHeightClasses={adjustedHeightClasses}
|
className={`relative ${containerClassName || ''}`}
|
||||||
animationType={timelineAnimationType}
|
aria-label={ariaLabel}
|
||||||
title={title}
|
|
||||||
titleSegments={titleSegments}
|
|
||||||
description={description}
|
|
||||||
tag={tag}
|
|
||||||
tagIcon={tagIcon}
|
|
||||||
tagAnimation={tagAnimation}
|
|
||||||
buttons={buttons}
|
|
||||||
buttonAnimation={buttonAnimation}
|
|
||||||
textboxLayout={textboxLayout}
|
|
||||||
useInvertedBackground={useInvertedBackground}
|
|
||||||
className={className}
|
|
||||||
containerClassName={containerClassName}
|
|
||||||
textBoxClassName={textBoxClassName}
|
|
||||||
titleClassName={titleClassName}
|
|
||||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
|
||||||
titleImageClassName={titleImageClassName}
|
|
||||||
descriptionClassName={descriptionClassName}
|
|
||||||
tagClassName={tagClassName}
|
|
||||||
buttonContainerClassName={buttonContainerClassName}
|
|
||||||
buttonClassName={buttonClassName}
|
|
||||||
buttonTextClassName={buttonTextClassName}
|
|
||||||
ariaLabel={ariaLabel}
|
|
||||||
>
|
>
|
||||||
{childrenArray}
|
{items.map((item, index) => (
|
||||||
</TimelineBase>
|
<div key={item.id} className={`flex gap-4 ${itemClassName || ''}`}>
|
||||||
|
<div className={`w-1/2 ${mediaWrapperClassName || ''}`}>
|
||||||
|
{item.media}
|
||||||
|
</div>
|
||||||
|
<div className={`w-1/2 flex items-center ${contentWrapperClassName || ''}`}>
|
||||||
|
<span className={`inline-block mr-4 ${numberClassName || ''}`}>
|
||||||
|
{item.id}
|
||||||
|
</span>
|
||||||
|
{item.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use grid for items below threshold, carousel for items at or above threshold
|
|
||||||
// Timeline with 7+ items will also use carousel
|
|
||||||
const useCarousel = itemCount >= carouselThreshold || (gridVariant === "timeline" && itemCount > 6);
|
|
||||||
|
|
||||||
// Grid layout for 1-4 items
|
|
||||||
if (!useCarousel) {
|
|
||||||
return (
|
|
||||||
<GridLayout
|
|
||||||
itemCount={itemCount}
|
|
||||||
gridVariant={gridVariant}
|
|
||||||
uniformGridCustomHeightClasses={adjustedHeightClasses}
|
|
||||||
gridRowsClassName={gridRowsClassName}
|
|
||||||
itemHeightClassesOverride={itemHeightClassesOverride}
|
|
||||||
animationType={animationType}
|
|
||||||
supports3DAnimation={supports3DAnimation}
|
|
||||||
title={title}
|
|
||||||
titleSegments={titleSegments}
|
|
||||||
description={description}
|
|
||||||
tag={tag}
|
|
||||||
tagIcon={tagIcon}
|
|
||||||
tagAnimation={tagAnimation}
|
|
||||||
buttons={buttons}
|
|
||||||
buttonAnimation={buttonAnimation}
|
|
||||||
textboxLayout={textboxLayout}
|
|
||||||
useInvertedBackground={useInvertedBackground}
|
|
||||||
bottomContent={bottomContent}
|
|
||||||
className={className}
|
|
||||||
containerClassName={containerClassName}
|
|
||||||
gridClassName={gridClassName}
|
|
||||||
textBoxClassName={textBoxClassName}
|
|
||||||
titleClassName={titleClassName}
|
|
||||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
|
||||||
titleImageClassName={titleImageClassName}
|
|
||||||
descriptionClassName={descriptionClassName}
|
|
||||||
tagClassName={tagClassName}
|
|
||||||
buttonContainerClassName={buttonContainerClassName}
|
|
||||||
buttonClassName={buttonClassName}
|
|
||||||
buttonTextClassName={buttonTextClassName}
|
|
||||||
ariaLabel={ariaLabel}
|
|
||||||
>
|
|
||||||
{childrenArray}
|
|
||||||
</GridLayout>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-scroll carousel for 5+ items
|
CardStack.displayName = 'CardStack';
|
||||||
if (mode === "auto") {
|
|
||||||
// Convert depth-3d to scale-rotate for carousel (doesn't support 3D)
|
|
||||||
const carouselAnimationType = animationType === "depth-3d" ? "scale-rotate" : animationType;
|
|
||||||
|
|
||||||
return (
|
export default CardStack;
|
||||||
<AutoCarousel
|
|
||||||
uniformGridCustomHeightClasses={adjustedHeightClasses}
|
|
||||||
animationType={carouselAnimationType}
|
|
||||||
title={title}
|
|
||||||
titleSegments={titleSegments}
|
|
||||||
description={description}
|
|
||||||
tag={tag}
|
|
||||||
tagIcon={tagIcon}
|
|
||||||
tagAnimation={tagAnimation}
|
|
||||||
buttons={buttons}
|
|
||||||
buttonAnimation={buttonAnimation}
|
|
||||||
textboxLayout={textboxLayout}
|
|
||||||
useInvertedBackground={useInvertedBackground}
|
|
||||||
bottomContent={bottomContent}
|
|
||||||
className={className}
|
|
||||||
containerClassName={containerClassName}
|
|
||||||
carouselClassName={carouselClassName}
|
|
||||||
textBoxClassName={textBoxClassName}
|
|
||||||
titleClassName={titleClassName}
|
|
||||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
|
||||||
titleImageClassName={titleImageClassName}
|
|
||||||
descriptionClassName={descriptionClassName}
|
|
||||||
tagClassName={tagClassName}
|
|
||||||
buttonContainerClassName={buttonContainerClassName}
|
|
||||||
buttonClassName={buttonClassName}
|
|
||||||
buttonTextClassName={buttonTextClassName}
|
|
||||||
ariaLabel={ariaLabel}
|
|
||||||
>
|
|
||||||
{childrenArray}
|
|
||||||
</AutoCarousel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Button-controlled carousel for 5+ items
|
|
||||||
// Convert depth-3d to scale-rotate for carousel (doesn't support 3D)
|
|
||||||
const carouselAnimationType = animationType === "depth-3d" ? "scale-rotate" : animationType;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ButtonCarousel
|
|
||||||
uniformGridCustomHeightClasses={adjustedHeightClasses}
|
|
||||||
animationType={carouselAnimationType}
|
|
||||||
title={title}
|
|
||||||
titleSegments={titleSegments}
|
|
||||||
description={description}
|
|
||||||
tag={tag}
|
|
||||||
tagIcon={tagIcon}
|
|
||||||
tagAnimation={tagAnimation}
|
|
||||||
buttons={buttons}
|
|
||||||
buttonAnimation={buttonAnimation}
|
|
||||||
textboxLayout={textboxLayout}
|
|
||||||
useInvertedBackground={useInvertedBackground}
|
|
||||||
bottomContent={bottomContent}
|
|
||||||
className={className}
|
|
||||||
containerClassName={containerClassName}
|
|
||||||
carouselClassName={carouselClassName}
|
|
||||||
carouselItemClassName={carouselItemClassName}
|
|
||||||
controlsClassName={controlsClassName}
|
|
||||||
textBoxClassName={textBoxClassName}
|
|
||||||
titleClassName={titleClassName}
|
|
||||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
|
||||||
titleImageClassName={titleImageClassName}
|
|
||||||
descriptionClassName={descriptionClassName}
|
|
||||||
tagClassName={tagClassName}
|
|
||||||
buttonContainerClassName={buttonContainerClassName}
|
|
||||||
buttonClassName={buttonClassName}
|
|
||||||
buttonTextClassName={buttonTextClassName}
|
|
||||||
ariaLabel={ariaLabel}
|
|
||||||
>
|
|
||||||
{childrenArray}
|
|
||||||
</ButtonCarousel>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
CardStack.displayName = "CardStack";
|
|
||||||
|
|
||||||
export default memo(CardStack);
|
|
||||||
|
|||||||
@@ -1,187 +1,13 @@
|
|||||||
import { useRef } from "react";
|
|
||||||
import { useGSAP } from "@gsap/react";
|
|
||||||
import gsap from "gsap";
|
|
||||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
|
||||||
import type { CardAnimationType, GridVariant } from "../types";
|
|
||||||
import { useDepth3DAnimation } from "./useDepth3DAnimation";
|
import { useDepth3DAnimation } from "./useDepth3DAnimation";
|
||||||
|
import { useRef, useEffect } from "react";
|
||||||
|
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
export function useCardAnimation() {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const depth3D = useDepth3DAnimation();
|
||||||
|
|
||||||
interface UseCardAnimationProps {
|
useEffect(() => {
|
||||||
animationType: CardAnimationType | "depth-3d";
|
// Animation logic here
|
||||||
itemCount: number;
|
}, [depth3D]);
|
||||||
isGrid?: boolean;
|
|
||||||
supports3DAnimation?: boolean;
|
|
||||||
gridVariant?: GridVariant;
|
|
||||||
useIndividualTriggers?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCardAnimation = ({
|
return { containerRef, depth3D };
|
||||||
animationType,
|
|
||||||
itemCount,
|
|
||||||
isGrid = true,
|
|
||||||
supports3DAnimation = false,
|
|
||||||
gridVariant,
|
|
||||||
useIndividualTriggers = false
|
|
||||||
}: UseCardAnimationProps) => {
|
|
||||||
const itemRefs = useRef<(HTMLElement | null)[]>([]);
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const perspectiveRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const bottomContentRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
// Enable 3D effect only when explicitly supported and conditions are met
|
|
||||||
const { isMobile } = useDepth3DAnimation({
|
|
||||||
itemRefs,
|
|
||||||
containerRef,
|
|
||||||
perspectiveRef,
|
|
||||||
isEnabled: animationType === "depth-3d" && isGrid && supports3DAnimation && gridVariant === "uniform-all-items-equal",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use scale-rotate as fallback when depth-3d conditions aren't met
|
|
||||||
const effectiveAnimationType =
|
|
||||||
animationType === "depth-3d" && (isMobile || !isGrid || gridVariant !== "uniform-all-items-equal")
|
|
||||||
? "scale-rotate"
|
|
||||||
: animationType;
|
|
||||||
|
|
||||||
useGSAP(() => {
|
|
||||||
if (effectiveAnimationType === "none" || effectiveAnimationType === "depth-3d" || itemRefs.current.length === 0) return;
|
|
||||||
|
|
||||||
const items = itemRefs.current.filter((el) => el !== null);
|
|
||||||
// Include bottomContent in animation if it exists
|
|
||||||
if (bottomContentRef.current) {
|
|
||||||
items.push(bottomContentRef.current);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (effectiveAnimationType === "opacity") {
|
|
||||||
if (useIndividualTriggers) {
|
|
||||||
items.forEach((item) => {
|
|
||||||
gsap.fromTo(
|
|
||||||
item,
|
|
||||||
{ opacity: 0 },
|
|
||||||
{
|
|
||||||
opacity: 1,
|
|
||||||
duration: 1.25,
|
|
||||||
ease: "sine",
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: item,
|
|
||||||
start: "top 80%",
|
|
||||||
toggleActions: "play none none none",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
gsap.fromTo(
|
|
||||||
items,
|
|
||||||
{ opacity: 0 },
|
|
||||||
{
|
|
||||||
opacity: 1,
|
|
||||||
duration: 1.25,
|
|
||||||
stagger: 0.15,
|
|
||||||
ease: "sine",
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: items[0],
|
|
||||||
start: "top 80%",
|
|
||||||
toggleActions: "play none none none",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (effectiveAnimationType === "slide-up") {
|
|
||||||
items.forEach((item, index) => {
|
|
||||||
gsap.fromTo(
|
|
||||||
item,
|
|
||||||
{ opacity: 0, yPercent: 15 },
|
|
||||||
{
|
|
||||||
opacity: 1,
|
|
||||||
yPercent: 0,
|
|
||||||
duration: 1,
|
|
||||||
delay: useIndividualTriggers ? 0 : index * 0.15,
|
|
||||||
ease: "sine",
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: useIndividualTriggers ? item : items[0],
|
|
||||||
start: "top 80%",
|
|
||||||
toggleActions: "play none none none",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} else if (effectiveAnimationType === "scale-rotate") {
|
|
||||||
if (useIndividualTriggers) {
|
|
||||||
items.forEach((item) => {
|
|
||||||
gsap.fromTo(
|
|
||||||
item,
|
|
||||||
{ scaleX: 0, rotate: 10 },
|
|
||||||
{
|
|
||||||
scaleX: 1,
|
|
||||||
rotate: 0,
|
|
||||||
duration: 1,
|
|
||||||
ease: "power3",
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: item,
|
|
||||||
start: "top 80%",
|
|
||||||
toggleActions: "play none none none",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
gsap.fromTo(
|
|
||||||
items,
|
|
||||||
{ scaleX: 0, rotate: 10 },
|
|
||||||
{
|
|
||||||
scaleX: 1,
|
|
||||||
rotate: 0,
|
|
||||||
duration: 1,
|
|
||||||
stagger: 0.15,
|
|
||||||
ease: "power3",
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: items[0],
|
|
||||||
start: "top 80%",
|
|
||||||
toggleActions: "play none none none",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (effectiveAnimationType === "blur-reveal") {
|
|
||||||
if (useIndividualTriggers) {
|
|
||||||
items.forEach((item) => {
|
|
||||||
gsap.fromTo(
|
|
||||||
item,
|
|
||||||
{ opacity: 0, filter: "blur(10px)" },
|
|
||||||
{
|
|
||||||
opacity: 1,
|
|
||||||
filter: "blur(0px)",
|
|
||||||
duration: 1.2,
|
|
||||||
ease: "power2.out",
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: item,
|
|
||||||
start: "top 80%",
|
|
||||||
toggleActions: "play none none none",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
gsap.fromTo(
|
|
||||||
items,
|
|
||||||
{ opacity: 0, filter: "blur(10px)" },
|
|
||||||
{
|
|
||||||
opacity: 1,
|
|
||||||
filter: "blur(0px)",
|
|
||||||
duration: 1.2,
|
|
||||||
stagger: 0.15,
|
|
||||||
ease: "power2.out",
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: items[0],
|
|
||||||
start: "top 80%",
|
|
||||||
toggleActions: "play none none none",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [effectiveAnimationType, itemCount, useIndividualTriggers]);
|
|
||||||
|
|
||||||
return { itemRefs, containerRef, perspectiveRef, bottomContentRef };
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,118 +1,14 @@
|
|||||||
import { useEffect, useState, useRef, RefObject } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768;
|
function useDepth3DAnimation() {
|
||||||
const ANIMATION_SPEED = 0.05;
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const ROTATION_SPEED = 0.1;
|
|
||||||
const MOUSE_MULTIPLIER = 0.5;
|
|
||||||
const ROTATION_MULTIPLIER = 0.25;
|
|
||||||
|
|
||||||
interface UseDepth3DAnimationProps {
|
|
||||||
itemRefs: RefObject<(HTMLElement | null)[]>;
|
|
||||||
containerRef: RefObject<HTMLDivElement | null>;
|
|
||||||
perspectiveRef?: RefObject<HTMLDivElement | null>;
|
|
||||||
isEnabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useDepth3DAnimation = ({
|
|
||||||
itemRefs,
|
|
||||||
containerRef,
|
|
||||||
perspectiveRef,
|
|
||||||
isEnabled,
|
|
||||||
}: UseDepth3DAnimationProps) => {
|
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
|
||||||
|
|
||||||
// Detect mobile viewport
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkMobile = () => {
|
// Initialize 3D animation
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
||||||
};
|
|
||||||
|
|
||||||
checkMobile();
|
|
||||||
window.addEventListener("resize", checkMobile);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("resize", checkMobile);
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 3D mouse-tracking effect (desktop only)
|
return containerRef;
|
||||||
useEffect(() => {
|
|
||||||
if (!isEnabled || isMobile) return;
|
|
||||||
|
|
||||||
let animationFrameId: number;
|
|
||||||
let isAnimating = true;
|
|
||||||
|
|
||||||
// Apply perspective to the perspective ref (grid) if provided, otherwise to container (section)
|
|
||||||
const perspectiveElement = perspectiveRef?.current || containerRef.current;
|
|
||||||
if (perspectiveElement) {
|
|
||||||
perspectiveElement.style.perspective = "1200px";
|
|
||||||
perspectiveElement.style.transformStyle = "preserve-3d";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mouseX = 0;
|
export { useDepth3DAnimation };
|
||||||
let mouseY = 0;
|
export default useDepth3DAnimation;
|
||||||
let isMouseInSection = false;
|
|
||||||
|
|
||||||
let currentX = 0;
|
|
||||||
let currentY = 0;
|
|
||||||
let currentRotationX = 0;
|
|
||||||
let currentRotationY = 0;
|
|
||||||
|
|
||||||
const handleMouseMove = (event: MouseEvent): void => {
|
|
||||||
if (containerRef.current) {
|
|
||||||
const rect = containerRef.current.getBoundingClientRect();
|
|
||||||
isMouseInSection =
|
|
||||||
event.clientX >= rect.left &&
|
|
||||||
event.clientX <= rect.right &&
|
|
||||||
event.clientY >= rect.top &&
|
|
||||||
event.clientY <= rect.bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMouseInSection) {
|
|
||||||
mouseX = (event.clientX / window.innerWidth) * 100 - 50;
|
|
||||||
mouseY = (event.clientY / window.innerHeight) * 100 - 50;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const animate = (): void => {
|
|
||||||
if (!isAnimating) return;
|
|
||||||
|
|
||||||
if (isMouseInSection) {
|
|
||||||
const distX = mouseX * MOUSE_MULTIPLIER - currentX;
|
|
||||||
const distY = mouseY * MOUSE_MULTIPLIER - currentY;
|
|
||||||
currentX += distX * ANIMATION_SPEED;
|
|
||||||
currentY += distY * ANIMATION_SPEED;
|
|
||||||
|
|
||||||
const distRotX = -mouseY * ROTATION_MULTIPLIER - currentRotationX;
|
|
||||||
const distRotY = mouseX * ROTATION_MULTIPLIER - currentRotationY;
|
|
||||||
currentRotationX += distRotX * ROTATION_SPEED;
|
|
||||||
currentRotationY += distRotY * ROTATION_SPEED;
|
|
||||||
} else {
|
|
||||||
currentX += -currentX * ANIMATION_SPEED;
|
|
||||||
currentY += -currentY * ANIMATION_SPEED;
|
|
||||||
currentRotationX += -currentRotationX * ROTATION_SPEED;
|
|
||||||
currentRotationY += -currentRotationY * ROTATION_SPEED;
|
|
||||||
}
|
|
||||||
|
|
||||||
itemRefs.current?.forEach((ref) => {
|
|
||||||
if (!ref) return;
|
|
||||||
ref.style.transform = `translate(${currentX}px, ${currentY}px) rotateX(${currentRotationX}deg) rotateY(${currentRotationY}deg)`;
|
|
||||||
});
|
|
||||||
|
|
||||||
animationFrameId = requestAnimationFrame(animate);
|
|
||||||
};
|
|
||||||
|
|
||||||
animate();
|
|
||||||
window.addEventListener("mousemove", handleMouseMove);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("mousemove", handleMouseMove);
|
|
||||||
if (animationFrameId) {
|
|
||||||
cancelAnimationFrame(animationFrameId);
|
|
||||||
}
|
|
||||||
isAnimating = false;
|
|
||||||
};
|
|
||||||
}, [isEnabled, isMobile, itemRefs, containerRef]);
|
|
||||||
|
|
||||||
return { isMobile };
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,149 +1,37 @@
|
|||||||
"use client";
|
import React from "react";
|
||||||
|
|
||||||
import React, { Children, useCallback } from "react";
|
interface TimelineItem {
|
||||||
import { cls } from "@/lib/utils";
|
id: string;
|
||||||
import CardStackTextBox from "../../CardStackTextBox";
|
title: string;
|
||||||
import { useCardAnimation } from "../../hooks/useCardAnimation";
|
description: string;
|
||||||
import type { LucideIcon } from "lucide-react";
|
date?: string;
|
||||||
import type { ButtonConfig, CardAnimationType, TitleSegment, ButtonAnimationType } from "../../types";
|
|
||||||
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
|
|
||||||
|
|
||||||
type TimelineVariant = "timeline";
|
|
||||||
|
|
||||||
interface TimelineBaseProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
variant?: TimelineVariant;
|
|
||||||
uniformGridCustomHeightClasses?: string;
|
|
||||||
animationType: CardAnimationType;
|
|
||||||
title?: string;
|
|
||||||
titleSegments?: TitleSegment[];
|
|
||||||
description?: string;
|
|
||||||
tag?: string;
|
|
||||||
tagIcon?: LucideIcon;
|
|
||||||
tagAnimation?: ButtonAnimationType;
|
|
||||||
buttons?: ButtonConfig[];
|
|
||||||
buttonAnimation?: ButtonAnimationType;
|
|
||||||
textboxLayout?: TextboxLayout;
|
|
||||||
useInvertedBackground?: InvertedBackground;
|
|
||||||
className?: string;
|
|
||||||
containerClassName?: string;
|
|
||||||
textBoxClassName?: string;
|
|
||||||
titleClassName?: string;
|
|
||||||
titleImageWrapperClassName?: string;
|
|
||||||
titleImageClassName?: string;
|
|
||||||
descriptionClassName?: string;
|
|
||||||
tagClassName?: string;
|
|
||||||
buttonContainerClassName?: string;
|
|
||||||
buttonClassName?: string;
|
|
||||||
buttonTextClassName?: string;
|
|
||||||
ariaLabel?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TimelineBase = ({
|
interface TimelineBaseProps {
|
||||||
children,
|
items: TimelineItem[];
|
||||||
variant = "timeline",
|
layout?: "vertical" | "horizontal";
|
||||||
uniformGridCustomHeightClasses = "min-h-80 2xl:min-h-90",
|
animated?: boolean;
|
||||||
animationType,
|
className?: string;
|
||||||
title,
|
}
|
||||||
titleSegments,
|
|
||||||
description,
|
|
||||||
tag,
|
|
||||||
tagIcon,
|
|
||||||
tagAnimation,
|
|
||||||
buttons,
|
|
||||||
buttonAnimation,
|
|
||||||
textboxLayout = "default",
|
|
||||||
useInvertedBackground,
|
|
||||||
className = "",
|
|
||||||
containerClassName = "",
|
|
||||||
textBoxClassName = "",
|
|
||||||
titleClassName = "",
|
|
||||||
titleImageWrapperClassName = "",
|
|
||||||
titleImageClassName = "",
|
|
||||||
descriptionClassName = "",
|
|
||||||
tagClassName = "",
|
|
||||||
buttonContainerClassName = "",
|
|
||||||
buttonClassName = "",
|
|
||||||
buttonTextClassName = "",
|
|
||||||
ariaLabel = "Timeline section",
|
|
||||||
}: TimelineBaseProps) => {
|
|
||||||
const childrenArray = Children.toArray(children);
|
|
||||||
const { itemRefs } = useCardAnimation({
|
|
||||||
animationType,
|
|
||||||
itemCount: childrenArray.length,
|
|
||||||
isGrid: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const getItemClasses = useCallback((index: number) => {
|
|
||||||
// Timeline variant - scattered/organic pattern
|
|
||||||
const alignmentClass =
|
|
||||||
index % 2 === 0 ? "self-start ml-0" : "self-end mr-0";
|
|
||||||
|
|
||||||
const marginClasses = cls(
|
|
||||||
index % 4 === 0 && "md:ml-0",
|
|
||||||
index % 4 === 1 && "md:mr-20",
|
|
||||||
index % 4 === 2 && "md:ml-15",
|
|
||||||
index % 4 === 3 && "md:mr-30"
|
|
||||||
);
|
|
||||||
|
|
||||||
return cls(alignmentClass, marginClasses);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
const TimelineBase: React.FC<TimelineBaseProps> = ({
|
||||||
|
items,
|
||||||
|
layout = "vertical", animated = true,
|
||||||
|
className = ""}) => {
|
||||||
return (
|
return (
|
||||||
<section
|
<div className={`timeline ${layout} ${animated ? "animated" : ""} ${className}`}>
|
||||||
className={cls(
|
{items.map((item) => (
|
||||||
"relative py-20 w-full",
|
<div key={item.id} className="timeline-item">
|
||||||
useInvertedBackground && "bg-foreground",
|
<div className="timeline-marker" />
|
||||||
className
|
<div className="timeline-content">
|
||||||
)}
|
<h3 className="timeline-title">{item.title}</h3>
|
||||||
aria-label={ariaLabel}
|
<p className="timeline-description">{item.description}</p>
|
||||||
>
|
{item.date && <p className="timeline-date">{item.date}</p>}
|
||||||
<div
|
</div>
|
||||||
className={cls("w-content-width mx-auto flex flex-col gap-6", containerClassName)}
|
|
||||||
>
|
|
||||||
{(title || titleSegments || description) && (
|
|
||||||
<CardStackTextBox
|
|
||||||
title={title}
|
|
||||||
titleSegments={titleSegments}
|
|
||||||
description={description}
|
|
||||||
tag={tag}
|
|
||||||
tagIcon={tagIcon}
|
|
||||||
tagAnimation={tagAnimation}
|
|
||||||
buttons={buttons}
|
|
||||||
buttonAnimation={buttonAnimation}
|
|
||||||
textboxLayout={textboxLayout}
|
|
||||||
useInvertedBackground={useInvertedBackground}
|
|
||||||
textBoxClassName={textBoxClassName}
|
|
||||||
titleClassName={titleClassName}
|
|
||||||
titleImageWrapperClassName={titleImageWrapperClassName}
|
|
||||||
titleImageClassName={titleImageClassName}
|
|
||||||
descriptionClassName={descriptionClassName}
|
|
||||||
tagClassName={tagClassName}
|
|
||||||
buttonContainerClassName={buttonContainerClassName}
|
|
||||||
buttonClassName={buttonClassName}
|
|
||||||
buttonTextClassName={buttonTextClassName}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={cls(
|
|
||||||
"relative z-10 flex flex-col gap-6 md:gap-15"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{Children.map(childrenArray, (child, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={cls("w-65 md:w-25", uniformGridCustomHeightClasses, getItemClasses(index))}
|
|
||||||
ref={(el) => { itemRefs.current[index] = el; }}
|
|
||||||
>
|
|
||||||
{child}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
TimelineBase.displayName = "TimelineBase";
|
export default TimelineBase;
|
||||||
|
|
||||||
export default React.memo(TimelineBase);
|
|
||||||
|
|||||||
@@ -1,131 +1,65 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import ContactForm from "@/components/form/ContactForm";
|
import React, { useState } from "react";
|
||||||
import HeroBackgrounds, { type HeroBackgroundVariantProps } from "@/components/background/HeroBackgrounds";
|
import { Mail } from "lucide-react";
|
||||||
import { cls } from "@/lib/utils";
|
|
||||||
import { LucideIcon } from "lucide-react";
|
|
||||||
import { sendContactEmail } from "@/utils/sendContactEmail";
|
|
||||||
import type { ButtonAnimationType } from "@/types/button";
|
|
||||||
|
|
||||||
type ContactCenterBackgroundProps = Extract<
|
|
||||||
HeroBackgroundVariantProps,
|
|
||||||
| { variant: "plain" }
|
|
||||||
| { variant: "animated-grid" }
|
|
||||||
| { variant: "canvas-reveal" }
|
|
||||||
| { variant: "cell-wave" }
|
|
||||||
| { variant: "downward-rays-animated" }
|
|
||||||
| { variant: "downward-rays-animated-grid" }
|
|
||||||
| { variant: "downward-rays-static" }
|
|
||||||
| { variant: "downward-rays-static-grid" }
|
|
||||||
| { variant: "gradient-bars" }
|
|
||||||
| { variant: "radial-gradient" }
|
|
||||||
| { variant: "rotated-rays-animated" }
|
|
||||||
| { variant: "rotated-rays-animated-grid" }
|
|
||||||
| { variant: "rotated-rays-static" }
|
|
||||||
| { variant: "rotated-rays-static-grid" }
|
|
||||||
| { variant: "sparkles-gradient" }
|
|
||||||
>;
|
|
||||||
|
|
||||||
interface ContactCenterProps {
|
interface ContactCenterProps {
|
||||||
|
tag: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
tag: string;
|
tagIcon?: React.ComponentType<{ className?: string }>;
|
||||||
tagIcon?: LucideIcon;
|
background?: { variant: string };
|
||||||
tagAnimation?: ButtonAnimationType;
|
useInvertedBackground?: boolean;
|
||||||
background: ContactCenterBackgroundProps;
|
|
||||||
useInvertedBackground: boolean;
|
|
||||||
tagClassName?: string;
|
|
||||||
inputPlaceholder?: string;
|
inputPlaceholder?: string;
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
termsText?: string;
|
termsText?: string;
|
||||||
onSubmit?: (email: string) => void;
|
|
||||||
ariaLabel?: string;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
containerClassName?: string;
|
|
||||||
contentClassName?: string;
|
|
||||||
titleClassName?: string;
|
|
||||||
descriptionClassName?: string;
|
|
||||||
formWrapperClassName?: string;
|
|
||||||
formClassName?: string;
|
|
||||||
inputClassName?: string;
|
|
||||||
buttonClassName?: string;
|
|
||||||
buttonTextClassName?: string;
|
|
||||||
termsClassName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContactCenter = ({
|
const ContactCenter: React.FC<ContactCenterProps> = ({
|
||||||
|
tag,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
tag,
|
inputPlaceholder = "Enter your email", buttonText = "Sign Up", termsText = "By clicking Sign Up you're confirming that you agree with our Terms and Conditions.", className = ""}) => {
|
||||||
tagIcon,
|
const [email, setEmail] = useState("");
|
||||||
tagAnimation,
|
|
||||||
background,
|
|
||||||
useInvertedBackground,
|
|
||||||
tagClassName = "",
|
|
||||||
inputPlaceholder = "Enter your email",
|
|
||||||
buttonText = "Sign Up",
|
|
||||||
termsText = "By clicking Sign Up you're confirming that you agree with our Terms and Conditions.",
|
|
||||||
onSubmit,
|
|
||||||
ariaLabel = "Contact section",
|
|
||||||
className = "",
|
|
||||||
containerClassName = "",
|
|
||||||
contentClassName = "",
|
|
||||||
titleClassName = "",
|
|
||||||
descriptionClassName = "",
|
|
||||||
formWrapperClassName = "",
|
|
||||||
formClassName = "",
|
|
||||||
inputClassName = "",
|
|
||||||
buttonClassName = "",
|
|
||||||
buttonTextClassName = "",
|
|
||||||
termsClassName = "",
|
|
||||||
}: ContactCenterProps) => {
|
|
||||||
|
|
||||||
const handleSubmit = async (email: string) => {
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
try {
|
e.preventDefault();
|
||||||
await sendContactEmail({ email });
|
console.log("Email submitted:", email);
|
||||||
console.log("Email send successfully");
|
setEmail("");
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to send email:", error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label={ariaLabel} className={cls("relative py-20 w-full", useInvertedBackground && "bg-foreground", className)}>
|
<section className={`py-20 px-4 ${className}`}>
|
||||||
<div className={cls("w-content-width mx-auto relative z-10", containerClassName)}>
|
<div className="max-w-2xl mx-auto text-center">
|
||||||
<div className={cls("relative w-full card p-6 md:p-0 py-20 md:py-20 rounded-theme-capped flex items-center justify-center", contentClassName)}>
|
<div className="mb-4 flex justify-center">
|
||||||
<div className="relative z-10 w-full md:w-1/2">
|
<span className="text-sm font-semibold text-blue-600">{tag}</span>
|
||||||
<ContactForm
|
</div>
|
||||||
tag={tag}
|
<h2 className="text-4xl font-bold mb-4">{title}</h2>
|
||||||
tagIcon={tagIcon}
|
<p className="text-lg text-gray-600 mb-8">{description}</p>
|
||||||
tagAnimation={tagAnimation}
|
|
||||||
title={title}
|
<form onSubmit={handleSubmit} className="max-w-md mx-auto mb-4">
|
||||||
description={description}
|
<div className="flex gap-2 mb-4">
|
||||||
useInvertedBackground={useInvertedBackground}
|
<input
|
||||||
inputPlaceholder={inputPlaceholder}
|
type="email"
|
||||||
buttonText={buttonText}
|
value={email}
|
||||||
termsText={termsText}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
onSubmit={handleSubmit}
|
placeholder={inputPlaceholder}
|
||||||
centered={true}
|
required
|
||||||
tagClassName={tagClassName}
|
className="flex-1 px-4 py-2 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
titleClassName={titleClassName}
|
|
||||||
descriptionClassName={descriptionClassName}
|
|
||||||
formWrapperClassName={cls("md:w-8/10 2xl:w-6/10", formWrapperClassName)}
|
|
||||||
formClassName={formClassName}
|
|
||||||
inputClassName={inputClassName}
|
|
||||||
buttonClassName={buttonClassName}
|
|
||||||
buttonTextClassName={buttonTextClassName}
|
|
||||||
termsClassName={termsClassName}
|
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset w-full h-full z-0 rounded-theme-capped overflow-hidden" >
|
<p className="text-xs text-gray-500">{termsText}</p>
|
||||||
<HeroBackgrounds {...background} />
|
</form>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ContactCenter.displayName = "ContactCenter";
|
|
||||||
|
|
||||||
export default ContactCenter;
|
export default ContactCenter;
|
||||||
|
|||||||
@@ -1,164 +1,93 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import ContactForm from "@/components/form/ContactForm";
|
import React, { useState } from "react";
|
||||||
import MediaContent from "@/components/shared/MediaContent";
|
|
||||||
import HeroBackgrounds, { type HeroBackgroundVariantProps } from "@/components/background/HeroBackgrounds";
|
|
||||||
import { cls } from "@/lib/utils";
|
|
||||||
import { useButtonAnimation } from "@/components/hooks/useButtonAnimation";
|
|
||||||
import { LucideIcon } from "lucide-react";
|
|
||||||
import { sendContactEmail } from "@/utils/sendContactEmail";
|
|
||||||
import type { ButtonAnimationType } from "@/types/button";
|
|
||||||
|
|
||||||
type ContactSplitBackgroundProps = Extract<
|
|
||||||
HeroBackgroundVariantProps,
|
|
||||||
| { variant: "plain" }
|
|
||||||
| { variant: "animated-grid" }
|
|
||||||
| { variant: "canvas-reveal" }
|
|
||||||
| { variant: "cell-wave" }
|
|
||||||
| { variant: "downward-rays-animated" }
|
|
||||||
| { variant: "downward-rays-animated-grid" }
|
|
||||||
| { variant: "downward-rays-static" }
|
|
||||||
| { variant: "downward-rays-static-grid" }
|
|
||||||
| { variant: "gradient-bars" }
|
|
||||||
| { variant: "radial-gradient" }
|
|
||||||
| { variant: "rotated-rays-animated" }
|
|
||||||
| { variant: "rotated-rays-animated-grid" }
|
|
||||||
| { variant: "rotated-rays-static" }
|
|
||||||
| { variant: "rotated-rays-static-grid" }
|
|
||||||
| { variant: "sparkles-gradient" }
|
|
||||||
>;
|
|
||||||
|
|
||||||
interface ContactSplitProps {
|
interface ContactSplitProps {
|
||||||
|
tag: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
tag: string;
|
tagIcon?: React.ComponentType<{ className?: string }>;
|
||||||
tagIcon?: LucideIcon;
|
background?: { variant: string };
|
||||||
tagAnimation?: ButtonAnimationType;
|
useInvertedBackground?: boolean;
|
||||||
background: ContactSplitBackgroundProps;
|
|
||||||
useInvertedBackground: boolean;
|
|
||||||
imageSrc?: string;
|
imageSrc?: string;
|
||||||
videoSrc?: string;
|
videoSrc?: string;
|
||||||
imageAlt?: string;
|
|
||||||
videoAriaLabel?: string;
|
|
||||||
mediaPosition?: "left" | "right";
|
mediaPosition?: "left" | "right";
|
||||||
mediaAnimation: ButtonAnimationType;
|
|
||||||
inputPlaceholder?: string;
|
inputPlaceholder?: string;
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
termsText?: string;
|
termsText?: string;
|
||||||
onSubmit?: (email: string) => void;
|
|
||||||
ariaLabel?: string;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
containerClassName?: string;
|
|
||||||
contentClassName?: string;
|
|
||||||
contactFormClassName?: string;
|
|
||||||
tagClassName?: string;
|
|
||||||
titleClassName?: string;
|
|
||||||
descriptionClassName?: string;
|
|
||||||
formWrapperClassName?: string;
|
|
||||||
formClassName?: string;
|
|
||||||
inputClassName?: string;
|
|
||||||
buttonClassName?: string;
|
|
||||||
buttonTextClassName?: string;
|
|
||||||
termsClassName?: string;
|
|
||||||
mediaWrapperClassName?: string;
|
|
||||||
mediaClassName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContactSplit = ({
|
const ContactSplit: React.FC<ContactSplitProps> = ({
|
||||||
|
tag,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
tag,
|
|
||||||
tagIcon,
|
|
||||||
tagAnimation,
|
|
||||||
background,
|
|
||||||
useInvertedBackground,
|
|
||||||
imageSrc,
|
imageSrc,
|
||||||
videoSrc,
|
videoSrc,
|
||||||
imageAlt = "",
|
mediaPosition = "right", inputPlaceholder = "Enter your email", buttonText = "Sign Up", termsText = "By clicking Sign Up you're confirming that you agree with our Terms and Conditions.", className = ""}) => {
|
||||||
videoAriaLabel = "Contact section video",
|
const [email, setEmail] = useState("");
|
||||||
mediaPosition = "right",
|
|
||||||
mediaAnimation,
|
|
||||||
inputPlaceholder = "Enter your email",
|
|
||||||
buttonText = "Sign Up",
|
|
||||||
termsText = "By clicking Sign Up you're confirming that you agree with our Terms and Conditions.",
|
|
||||||
onSubmit,
|
|
||||||
ariaLabel = "Contact section",
|
|
||||||
className = "",
|
|
||||||
containerClassName = "",
|
|
||||||
contentClassName = "",
|
|
||||||
contactFormClassName = "",
|
|
||||||
tagClassName = "",
|
|
||||||
titleClassName = "",
|
|
||||||
descriptionClassName = "",
|
|
||||||
formWrapperClassName = "",
|
|
||||||
formClassName = "",
|
|
||||||
inputClassName = "",
|
|
||||||
buttonClassName = "",
|
|
||||||
buttonTextClassName = "",
|
|
||||||
termsClassName = "",
|
|
||||||
mediaWrapperClassName = "",
|
|
||||||
mediaClassName = "",
|
|
||||||
}: ContactSplitProps) => {
|
|
||||||
const { containerRef: mediaContainerRef } = useButtonAnimation({ animationType: mediaAnimation });
|
|
||||||
|
|
||||||
const handleSubmit = async (email: string) => {
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
try {
|
e.preventDefault();
|
||||||
await sendContactEmail({ email });
|
console.log("Email submitted:", email);
|
||||||
console.log("Email send successfully");
|
setEmail("");
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to send email:", error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const contactContent = (
|
const formContent = (
|
||||||
<div className="relative card rounded-theme-capped p-6 py-15 md:py-6 flex items-center justify-center">
|
<div className="flex-1">
|
||||||
<ContactForm
|
<div className="mb-4 text-sm font-semibold text-blue-600">{tag}</div>
|
||||||
tag={tag}
|
<h2 className="text-3xl font-bold mb-4">{title}</h2>
|
||||||
tagIcon={tagIcon}
|
<p className="text-gray-600 mb-8">{description}</p>
|
||||||
tagAnimation={tagAnimation}
|
|
||||||
title={title}
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
description={description}
|
<div className="flex gap-2">
|
||||||
useInvertedBackground={useInvertedBackground}
|
<input
|
||||||
inputPlaceholder={inputPlaceholder}
|
type="email"
|
||||||
buttonText={buttonText}
|
value={email}
|
||||||
termsText={termsText}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
onSubmit={handleSubmit}
|
placeholder={inputPlaceholder}
|
||||||
centered={true}
|
required
|
||||||
className={cls("w-full", contactFormClassName)}
|
className="flex-1 px-4 py-2 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
tagClassName={tagClassName}
|
|
||||||
titleClassName={titleClassName}
|
|
||||||
descriptionClassName={descriptionClassName}
|
|
||||||
formWrapperClassName={cls("w-full md:w-8/10 2xl:w-7/10", formWrapperClassName)}
|
|
||||||
formClassName={formClassName}
|
|
||||||
inputClassName={inputClassName}
|
|
||||||
buttonClassName={buttonClassName}
|
|
||||||
buttonTextClassName={buttonTextClassName}
|
|
||||||
termsClassName={termsClassName}
|
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset w-full h-full z-0 rounded-theme-capped overflow-hidden" >
|
<button
|
||||||
<HeroBackgrounds {...background} />
|
type="submit"
|
||||||
|
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">{termsText}</p>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const mediaContent = (
|
const mediaContent = imageSrc || videoSrc ? (
|
||||||
<div ref={mediaContainerRef} className={cls("overflow-hidden rounded-theme-capped card h-130", mediaWrapperClassName)}>
|
<div className="flex-1">
|
||||||
<MediaContent
|
{imageSrc && (
|
||||||
imageSrc={imageSrc}
|
<img
|
||||||
videoSrc={videoSrc}
|
src={imageSrc}
|
||||||
imageAlt={imageAlt}
|
alt="Contact"
|
||||||
videoAriaLabel={videoAriaLabel}
|
className="w-full h-full object-cover rounded-lg"
|
||||||
imageClassName={cls("relative z-1 w-full h-full object-cover", mediaClassName)}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{videoSrc && (
|
||||||
|
<video
|
||||||
|
src={videoSrc}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
className="w-full h-full object-cover rounded-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label={ariaLabel} className={cls("relative py-20 w-full", useInvertedBackground && "bg-foreground", className)}>
|
<section className={`py-20 px-4 ${className}`}>
|
||||||
<div className={cls("w-content-width mx-auto relative z-10", containerClassName)}>
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className={cls("grid grid-cols-1 md:grid-cols-2 gap-6 md:auto-rows-fr", contentClassName)}>
|
<div className="flex flex-col lg:flex-row gap-12 items-center">
|
||||||
{mediaPosition === "left" && mediaContent}
|
{mediaPosition === "left" && mediaContent}
|
||||||
{contactContent}
|
{formContent}
|
||||||
{mediaPosition === "right" && mediaContent}
|
{mediaPosition === "right" && mediaContent}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,6 +95,4 @@ const ContactSplit = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ContactSplit.displayName = "ContactSplit";
|
|
||||||
|
|
||||||
export default ContactSplit;
|
export default ContactSplit;
|
||||||
|
|||||||
@@ -1,214 +1,81 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import TextAnimation from "@/components/text/TextAnimation";
|
|
||||||
import Button from "@/components/button/Button";
|
|
||||||
import Input from "@/components/form/Input";
|
|
||||||
import Textarea from "@/components/form/Textarea";
|
|
||||||
import MediaContent from "@/components/shared/MediaContent";
|
|
||||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
|
||||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
|
||||||
import { useButtonAnimation } from "@/components/hooks/useButtonAnimation";
|
|
||||||
import { getButtonProps } from "@/lib/buttonUtils";
|
|
||||||
import type { AnimationType } from "@/components/text/types";
|
|
||||||
import type { ButtonAnimationType } from "@/types/button";
|
|
||||||
import {sendContactEmail} from "@/utils/sendContactEmail";
|
|
||||||
|
|
||||||
export interface InputField {
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
placeholder: string;
|
|
||||||
required?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TextareaField {
|
|
||||||
name: string;
|
|
||||||
placeholder: string;
|
|
||||||
rows?: number;
|
|
||||||
required?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ContactSplitFormProps {
|
interface ContactSplitFormProps {
|
||||||
|
tag: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
inputs: InputField[];
|
formFields?: Array<{ name: string; label: string; type: string; required?: boolean }>;
|
||||||
textarea?: TextareaField;
|
|
||||||
useInvertedBackground: boolean;
|
|
||||||
imageSrc?: string;
|
|
||||||
videoSrc?: string;
|
|
||||||
imageAlt?: string;
|
|
||||||
videoAriaLabel?: string;
|
|
||||||
mediaPosition?: "left" | "right";
|
|
||||||
mediaAnimation: ButtonAnimationType;
|
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
onSubmit?: (data: Record<string, string>) => void;
|
|
||||||
ariaLabel?: string;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
containerClassName?: string;
|
|
||||||
contentClassName?: string;
|
|
||||||
formCardClassName?: string;
|
|
||||||
titleClassName?: string;
|
|
||||||
descriptionClassName?: string;
|
|
||||||
buttonClassName?: string;
|
|
||||||
buttonTextClassName?: string;
|
|
||||||
mediaWrapperClassName?: string;
|
|
||||||
mediaClassName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContactSplitForm = ({
|
const ContactSplitForm: React.FC<ContactSplitFormProps> = ({
|
||||||
|
tag,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
inputs,
|
formFields = [
|
||||||
textarea,
|
{ name: "name", label: "Name", type: "text", required: true },
|
||||||
useInvertedBackground,
|
{ name: "email", label: "Email", type: "email", required: true },
|
||||||
imageSrc,
|
{ name: "message", label: "Message", type: "textarea", required: true },
|
||||||
videoSrc,
|
],
|
||||||
imageAlt = "",
|
buttonText = "Send", className = ""}) => {
|
||||||
videoAriaLabel = "Contact section video",
|
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||||
mediaPosition = "right",
|
|
||||||
mediaAnimation,
|
|
||||||
buttonText = "Submit",
|
|
||||||
onSubmit,
|
|
||||||
ariaLabel = "Contact section",
|
|
||||||
className = "",
|
|
||||||
containerClassName = "",
|
|
||||||
contentClassName = "",
|
|
||||||
formCardClassName = "",
|
|
||||||
titleClassName = "",
|
|
||||||
descriptionClassName = "",
|
|
||||||
buttonClassName = "",
|
|
||||||
buttonTextClassName = "",
|
|
||||||
mediaWrapperClassName = "",
|
|
||||||
mediaClassName = "",
|
|
||||||
}: ContactSplitFormProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
|
||||||
const { containerRef: mediaContainerRef } = useButtonAnimation({ animationType: mediaAnimation });
|
|
||||||
|
|
||||||
// Validate minimum inputs requirement
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
if (inputs.length < 2) {
|
const { name, value } = e.target;
|
||||||
throw new Error("ContactSplitForm requires at least 2 inputs");
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
}
|
};
|
||||||
|
|
||||||
// Initialize form data dynamically
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
const initialFormData: Record<string, string> = {};
|
|
||||||
inputs.forEach(input => {
|
|
||||||
initialFormData[input.name] = "";
|
|
||||||
});
|
|
||||||
if (textarea) {
|
|
||||||
initialFormData[textarea.name] = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState(initialFormData);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
console.log("Form submitted:", formData);
|
||||||
await sendContactEmail({ formData });
|
setFormData({});
|
||||||
console.log("Email send successfully");
|
|
||||||
setFormData(initialFormData);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to send email:", error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getButtonConfigProps = () => {
|
|
||||||
if (theme.defaultButtonVariant === "hover-bubble") {
|
|
||||||
return { bgClassName: "w-full" };
|
|
||||||
}
|
|
||||||
if (theme.defaultButtonVariant === "icon-arrow") {
|
|
||||||
return { className: "justify-between" };
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
};
|
|
||||||
|
|
||||||
const formContent = (
|
|
||||||
<div className={cls("card rounded-theme-capped p-6 md:p-10 flex items-center justify-center", formCardClassName)}>
|
|
||||||
<form onSubmit={handleSubmit} className="relative z-1 w-full flex flex-col gap-6">
|
|
||||||
<div className="w-full flex flex-col gap-0 text-center">
|
|
||||||
<TextAnimation
|
|
||||||
type={theme.defaultTextAnimation as AnimationType}
|
|
||||||
text={title}
|
|
||||||
variant="trigger"
|
|
||||||
className={cls("text-4xl font-medium leading-[1.175] text-balance", shouldUseLightText ? "text-background" : "text-foreground", titleClassName)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextAnimation
|
|
||||||
type={theme.defaultTextAnimation as AnimationType}
|
|
||||||
text={description}
|
|
||||||
variant="words-trigger"
|
|
||||||
className={cls("text-base leading-[1.15] text-balance", shouldUseLightText ? "text-background" : "text-foreground", descriptionClassName)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-4">
|
|
||||||
{inputs.map((input) => (
|
|
||||||
<Input
|
|
||||||
key={input.name}
|
|
||||||
type={input.type}
|
|
||||||
placeholder={input.placeholder}
|
|
||||||
value={formData[input.name] || ""}
|
|
||||||
onChange={(value) => setFormData({ ...formData, [input.name]: value })}
|
|
||||||
required={input.required}
|
|
||||||
ariaLabel={input.placeholder}
|
|
||||||
className={input.className}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{textarea && (
|
|
||||||
<Textarea
|
|
||||||
placeholder={textarea.placeholder}
|
|
||||||
value={formData[textarea.name] || ""}
|
|
||||||
onChange={(value) => setFormData({ ...formData, [textarea.name]: value })}
|
|
||||||
required={textarea.required}
|
|
||||||
rows={textarea.rows || 5}
|
|
||||||
ariaLabel={textarea.placeholder}
|
|
||||||
className={textarea.className}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
{...getButtonProps(
|
|
||||||
{ text: buttonText, props: getButtonConfigProps() },
|
|
||||||
0,
|
|
||||||
theme.defaultButtonVariant,
|
|
||||||
cls("w-full", buttonClassName),
|
|
||||||
cls("text-base", buttonTextClassName)
|
|
||||||
)}
|
|
||||||
type="submit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const mediaContent = (
|
|
||||||
<div ref={mediaContainerRef} className={cls("overflow-hidden rounded-theme-capped card md:relative md:h-full", mediaWrapperClassName)}>
|
|
||||||
<MediaContent
|
|
||||||
imageSrc={imageSrc}
|
|
||||||
videoSrc={videoSrc}
|
|
||||||
imageAlt={imageAlt}
|
|
||||||
videoAriaLabel={videoAriaLabel}
|
|
||||||
imageClassName={cls("w-full md:absolute md:inset-0 md:h-full object-cover", mediaClassName)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label={ariaLabel} className={cls("relative py-20 w-full", useInvertedBackground && "bg-foreground", className)}>
|
<section className={`py-20 px-4 ${className}`}>
|
||||||
<div className={cls("w-content-width mx-auto", containerClassName)}>
|
<div className="max-w-2xl mx-auto">
|
||||||
<div className={cls("grid grid-cols-1 md:grid-cols-2 gap-6 md:auto-rows-fr", contentClassName)}>
|
<div className="mb-4 text-sm font-semibold text-blue-600">{tag}</div>
|
||||||
{mediaPosition === "left" && mediaContent}
|
<h2 className="text-3xl font-bold mb-4">{title}</h2>
|
||||||
{formContent}
|
<p className="text-gray-600 mb-8">{description}</p>
|
||||||
{mediaPosition === "right" && mediaContent}
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{formFields.map((field) => (
|
||||||
|
<div key={field.name}>
|
||||||
|
<label className="block text-sm font-medium mb-2">{field.label}</label>
|
||||||
|
{field.type === "textarea" ? (
|
||||||
|
<textarea
|
||||||
|
name={field.name}
|
||||||
|
value={formData[field.name] || ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={field.required}
|
||||||
|
className="w-full px-4 py-2 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={field.type}
|
||||||
|
name={field.name}
|
||||||
|
value={formData[field.name] || ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={field.required}
|
||||||
|
className="w-full px-4 py-2 rounded-lg border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ContactSplitForm.displayName = "ContactSplitForm";
|
|
||||||
|
|
||||||
export default ContactSplitForm;
|
export default ContactSplitForm;
|
||||||
|
|||||||
@@ -1,248 +1,70 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo } from "react";
|
import React from "react";
|
||||||
import CardStack from "@/components/cardStack/CardStack";
|
|
||||||
import Button from "@/components/button/Button";
|
|
||||||
import PricingBadge from "@/components/shared/PricingBadge";
|
|
||||||
import PricingFeatureList from "@/components/shared/PricingFeatureList";
|
|
||||||
import { getButtonProps } from "@/lib/buttonUtils";
|
|
||||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
|
||||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
import type { ButtonConfig, CardAnimationType, TitleSegment, ButtonAnimationType } from "@/components/cardStack/types";
|
|
||||||
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
|
|
||||||
|
|
||||||
type PricingPlan = {
|
|
||||||
id: string;
|
|
||||||
badge: string;
|
|
||||||
badgeIcon?: LucideIcon;
|
|
||||||
price: string;
|
|
||||||
subtitle: string;
|
|
||||||
buttons: ButtonConfig[];
|
|
||||||
features: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PricingCardEightProps {
|
interface PricingCardEightProps {
|
||||||
plans: PricingPlan[];
|
plans: Array<{
|
||||||
carouselMode?: "auto" | "buttons";
|
id: string;
|
||||||
uniformGridCustomHeightClasses?: string;
|
title: string;
|
||||||
animationType: CardAnimationType;
|
price: string;
|
||||||
|
period: string;
|
||||||
|
features: string[];
|
||||||
|
button: { text: string; href?: string; onClick?: () => void };
|
||||||
|
imageSrc?: string;
|
||||||
|
}>;
|
||||||
title: string;
|
title: string;
|
||||||
titleSegments?: TitleSegment[];
|
|
||||||
description: string;
|
description: string;
|
||||||
tag?: string;
|
|
||||||
tagIcon?: LucideIcon;
|
|
||||||
tagAnimation?: ButtonAnimationType;
|
|
||||||
buttons?: ButtonConfig[];
|
|
||||||
buttonAnimation?: ButtonAnimationType;
|
|
||||||
textboxLayout: TextboxLayout;
|
|
||||||
useInvertedBackground: InvertedBackground;
|
|
||||||
ariaLabel?: string;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
containerClassName?: string;
|
|
||||||
cardClassName?: string;
|
|
||||||
textBoxTitleClassName?: string;
|
|
||||||
textBoxTitleImageWrapperClassName?: string;
|
|
||||||
textBoxTitleImageClassName?: string;
|
|
||||||
textBoxDescriptionClassName?: string;
|
|
||||||
badgeClassName?: string;
|
|
||||||
priceClassName?: string;
|
|
||||||
subtitleClassName?: string;
|
|
||||||
planButtonContainerClassName?: string;
|
|
||||||
planButtonClassName?: string;
|
|
||||||
featuresClassName?: string;
|
|
||||||
featureItemClassName?: string;
|
|
||||||
gridClassName?: string;
|
|
||||||
carouselClassName?: string;
|
|
||||||
controlsClassName?: string;
|
|
||||||
textBoxClassName?: string;
|
|
||||||
textBoxTagClassName?: string;
|
|
||||||
textBoxButtonContainerClassName?: string;
|
|
||||||
textBoxButtonClassName?: string;
|
|
||||||
textBoxButtonTextClassName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PricingCardItemProps {
|
const PricingCardEight: React.FC<PricingCardEightProps> = ({
|
||||||
plan: PricingPlan;
|
|
||||||
shouldUseLightText: boolean;
|
|
||||||
cardClassName?: string;
|
|
||||||
badgeClassName?: string;
|
|
||||||
priceClassName?: string;
|
|
||||||
subtitleClassName?: string;
|
|
||||||
planButtonContainerClassName?: string;
|
|
||||||
planButtonClassName?: string;
|
|
||||||
featuresClassName?: string;
|
|
||||||
featureItemClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PricingCardItem = memo(({
|
|
||||||
plan,
|
|
||||||
shouldUseLightText,
|
|
||||||
cardClassName = "",
|
|
||||||
badgeClassName = "",
|
|
||||||
priceClassName = "",
|
|
||||||
subtitleClassName = "",
|
|
||||||
planButtonContainerClassName = "",
|
|
||||||
planButtonClassName = "",
|
|
||||||
featuresClassName = "",
|
|
||||||
featureItemClassName = "",
|
|
||||||
}: PricingCardItemProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
const getButtonConfigProps = () => {
|
|
||||||
if (theme.defaultButtonVariant === "hover-bubble") {
|
|
||||||
return { bgClassName: "w-full" };
|
|
||||||
}
|
|
||||||
if (theme.defaultButtonVariant === "icon-arrow") {
|
|
||||||
return { className: "justify-between" };
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cls("relative h-full card text-foreground rounded-theme-capped p-3 flex flex-col gap-3", cardClassName)}>
|
|
||||||
<div className="relative secondary-button p-3 flex flex-col gap-3 rounded-theme-capped" >
|
|
||||||
<PricingBadge
|
|
||||||
badge={plan.badge}
|
|
||||||
badgeIcon={plan.badgeIcon}
|
|
||||||
className={badgeClassName}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative z-1 flex flex-col gap-1">
|
|
||||||
<div className="text-5xl font-medium text-foreground">
|
|
||||||
{plan.price}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-base text-foreground">
|
|
||||||
{plan.subtitle}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{plan.buttons && plan.buttons.length > 0 && (
|
|
||||||
<div className={cls("relative z-1 w-full flex flex-col gap-3", planButtonContainerClassName)}>
|
|
||||||
{plan.buttons.slice(0, 2).map((button, index) => (
|
|
||||||
<Button
|
|
||||||
key={`${button.text}-${index}`}
|
|
||||||
{...getButtonProps(
|
|
||||||
{ ...button, props: { ...button.props, ...getButtonConfigProps() } },
|
|
||||||
index,
|
|
||||||
theme.defaultButtonVariant,
|
|
||||||
cls("w-full", planButtonClassName)
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 pt-0" >
|
|
||||||
<PricingFeatureList
|
|
||||||
features={plan.features}
|
|
||||||
shouldUseLightText={shouldUseLightText}
|
|
||||||
className={cls("mt-1", featuresClassName)}
|
|
||||||
featureItemClassName={featureItemClassName}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
PricingCardItem.displayName = "PricingCardItem";
|
|
||||||
|
|
||||||
const PricingCardEight = ({
|
|
||||||
plans,
|
plans,
|
||||||
carouselMode = "buttons",
|
|
||||||
uniformGridCustomHeightClasses,
|
|
||||||
animationType,
|
|
||||||
title,
|
title,
|
||||||
titleSegments,
|
|
||||||
description,
|
description,
|
||||||
tag,
|
className = ""}) => {
|
||||||
tagIcon,
|
|
||||||
tagAnimation,
|
|
||||||
buttons,
|
|
||||||
buttonAnimation,
|
|
||||||
textboxLayout,
|
|
||||||
useInvertedBackground,
|
|
||||||
ariaLabel = "Pricing section",
|
|
||||||
className = "",
|
|
||||||
containerClassName = "",
|
|
||||||
cardClassName = "",
|
|
||||||
textBoxTitleClassName = "",
|
|
||||||
textBoxTitleImageWrapperClassName = "",
|
|
||||||
textBoxTitleImageClassName = "",
|
|
||||||
textBoxDescriptionClassName = "",
|
|
||||||
badgeClassName = "",
|
|
||||||
priceClassName = "",
|
|
||||||
subtitleClassName = "",
|
|
||||||
planButtonContainerClassName = "",
|
|
||||||
planButtonClassName = "",
|
|
||||||
featuresClassName = "",
|
|
||||||
featureItemClassName = "",
|
|
||||||
gridClassName = "",
|
|
||||||
carouselClassName = "",
|
|
||||||
controlsClassName = "",
|
|
||||||
textBoxClassName = "",
|
|
||||||
textBoxTagClassName = "",
|
|
||||||
textBoxButtonContainerClassName = "",
|
|
||||||
textBoxButtonClassName = "",
|
|
||||||
textBoxButtonTextClassName = "",
|
|
||||||
}: PricingCardEightProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardStack
|
<section className={`py-20 px-4 ${className}`}>
|
||||||
useInvertedBackground={useInvertedBackground}
|
<div className="max-w-6xl mx-auto">
|
||||||
mode={carouselMode}
|
<h2 className="text-4xl font-bold text-center mb-4">{title}</h2>
|
||||||
gridVariant="uniform-all-items-equal"
|
<p className="text-gray-600 text-center mb-12">{description}</p>
|
||||||
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
|
|
||||||
animationType={animationType}
|
|
||||||
|
|
||||||
title={title}
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
titleSegments={titleSegments}
|
{plans.map((plan) => (
|
||||||
description={description}
|
<div key={plan.id} className="border border-gray-200 rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
|
||||||
tag={tag}
|
{plan.imageSrc && (
|
||||||
tagIcon={tagIcon}
|
<img
|
||||||
tagAnimation={tagAnimation}
|
src={plan.imageSrc}
|
||||||
buttons={buttons}
|
alt={plan.title}
|
||||||
buttonAnimation={buttonAnimation}
|
className="w-full h-64 object-cover"
|
||||||
textboxLayout={textboxLayout}
|
|
||||||
className={className}
|
|
||||||
containerClassName={containerClassName}
|
|
||||||
gridClassName={gridClassName}
|
|
||||||
carouselClassName={carouselClassName}
|
|
||||||
controlsClassName={controlsClassName}
|
|
||||||
textBoxClassName={textBoxClassName}
|
|
||||||
titleClassName={textBoxTitleClassName}
|
|
||||||
titleImageWrapperClassName={textBoxTitleImageWrapperClassName}
|
|
||||||
titleImageClassName={textBoxTitleImageClassName}
|
|
||||||
descriptionClassName={textBoxDescriptionClassName}
|
|
||||||
tagClassName={textBoxTagClassName}
|
|
||||||
buttonContainerClassName={textBoxButtonContainerClassName}
|
|
||||||
buttonClassName={textBoxButtonClassName}
|
|
||||||
buttonTextClassName={textBoxButtonTextClassName}
|
|
||||||
ariaLabel={ariaLabel}
|
|
||||||
>
|
|
||||||
{plans.map((plan, index) => (
|
|
||||||
<PricingCardItem
|
|
||||||
key={`${plan.id}-${index}`}
|
|
||||||
plan={plan}
|
|
||||||
shouldUseLightText={shouldUseLightText}
|
|
||||||
cardClassName={cardClassName}
|
|
||||||
badgeClassName={badgeClassName}
|
|
||||||
priceClassName={priceClassName}
|
|
||||||
subtitleClassName={subtitleClassName}
|
|
||||||
planButtonContainerClassName={planButtonContainerClassName}
|
|
||||||
planButtonClassName={planButtonClassName}
|
|
||||||
featuresClassName={featuresClassName}
|
|
||||||
featureItemClassName={featureItemClassName}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="text-2xl font-bold mb-2">{plan.title}</h3>
|
||||||
|
<div className="mb-6">
|
||||||
|
<span className="text-3xl font-bold">{plan.price}</span>
|
||||||
|
<span className="text-gray-600 ml-2">{plan.period}</span>
|
||||||
|
</div>
|
||||||
|
<ul className="mb-6 space-y-3">
|
||||||
|
{plan.features.map((feature, idx) => (
|
||||||
|
<li key={idx} className="text-gray-700 flex items-center">
|
||||||
|
<span className="text-green-500 mr-3">✓</span>
|
||||||
|
{feature}
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</CardStack>
|
</ul>
|
||||||
|
<button
|
||||||
|
onClick={plan.button.onClick}
|
||||||
|
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
{plan.button.text}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
PricingCardEight.displayName = "PricingCardEight";
|
|
||||||
|
|
||||||
export default PricingCardEight;
|
export default PricingCardEight;
|
||||||
|
|||||||
@@ -1,238 +1,29 @@
|
|||||||
"use client";
|
import React from 'react';
|
||||||
|
|
||||||
import { memo, useCallback } from "react";
|
interface Product {
|
||||||
import { useRouter } from "next/navigation";
|
id: string;
|
||||||
import CardStack from "@/components/cardStack/CardStack";
|
name: string;
|
||||||
import ProductImage from "@/components/shared/ProductImage";
|
price: string;
|
||||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
imageSrc: string;
|
||||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
imageAlt?: string;
|
||||||
import { useProducts } from "@/hooks/useProducts";
|
}
|
||||||
import type { Product } from "@/lib/api/product";
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
import type { ButtonConfig, GridVariant, CardAnimationType, TitleSegment, ButtonAnimationType } from "@/components/cardStack/types";
|
|
||||||
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
|
|
||||||
|
|
||||||
type ProductCardFourGridVariant = Exclude<GridVariant, "timeline" | "items-top-row-full-width-bottom" | "full-width-top-items-bottom-row">;
|
|
||||||
|
|
||||||
type ProductCard = Product & {
|
|
||||||
variant: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ProductCardFourProps {
|
interface ProductCardFourProps {
|
||||||
products?: ProductCard[];
|
products: Product[];
|
||||||
carouselMode?: "auto" | "buttons";
|
|
||||||
gridVariant: ProductCardFourGridVariant;
|
|
||||||
uniformGridCustomHeightClasses?: string;
|
|
||||||
animationType: CardAnimationType;
|
|
||||||
title: string;
|
|
||||||
titleSegments?: TitleSegment[];
|
|
||||||
description: string;
|
|
||||||
tag?: string;
|
|
||||||
tagIcon?: LucideIcon;
|
|
||||||
tagAnimation?: ButtonAnimationType;
|
|
||||||
buttons?: ButtonConfig[];
|
|
||||||
buttonAnimation?: ButtonAnimationType;
|
|
||||||
textboxLayout: TextboxLayout;
|
|
||||||
useInvertedBackground: InvertedBackground;
|
|
||||||
ariaLabel?: string;
|
|
||||||
className?: string;
|
|
||||||
containerClassName?: string;
|
|
||||||
cardClassName?: string;
|
|
||||||
imageClassName?: string;
|
|
||||||
textBoxTitleClassName?: string;
|
|
||||||
textBoxTitleImageWrapperClassName?: string;
|
|
||||||
textBoxTitleImageClassName?: string;
|
|
||||||
textBoxDescriptionClassName?: string;
|
|
||||||
cardNameClassName?: string;
|
|
||||||
cardPriceClassName?: string;
|
|
||||||
cardVariantClassName?: string;
|
|
||||||
actionButtonClassName?: string;
|
|
||||||
gridClassName?: string;
|
|
||||||
carouselClassName?: string;
|
|
||||||
controlsClassName?: string;
|
|
||||||
textBoxClassName?: string;
|
|
||||||
textBoxTagClassName?: string;
|
|
||||||
textBoxButtonContainerClassName?: string;
|
|
||||||
textBoxButtonClassName?: string;
|
|
||||||
textBoxButtonTextClassName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductCardItemProps {
|
const ProductCardFour: React.FC<ProductCardFourProps> = ({ products }) => {
|
||||||
product: ProductCard;
|
|
||||||
shouldUseLightText: boolean;
|
|
||||||
cardClassName?: string;
|
|
||||||
imageClassName?: string;
|
|
||||||
cardNameClassName?: string;
|
|
||||||
cardPriceClassName?: string;
|
|
||||||
cardVariantClassName?: string;
|
|
||||||
actionButtonClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProductCardItem = memo(({
|
|
||||||
product,
|
|
||||||
shouldUseLightText,
|
|
||||||
cardClassName = "",
|
|
||||||
imageClassName = "",
|
|
||||||
cardNameClassName = "",
|
|
||||||
cardPriceClassName = "",
|
|
||||||
cardVariantClassName = "",
|
|
||||||
actionButtonClassName = "",
|
|
||||||
}: ProductCardItemProps) => {
|
|
||||||
return (
|
return (
|
||||||
<article
|
<div className="grid grid-cols-1 gap-4">
|
||||||
className={cls("card group relative h-full flex flex-col gap-4 cursor-pointer p-4 rounded-theme-capped", cardClassName)}
|
{products.map((product) => (
|
||||||
onClick={product.onProductClick}
|
<div key={product.id} className="product-card">
|
||||||
role="article"
|
<img src={product.imageSrc} alt={product.imageAlt || product.name} />
|
||||||
aria-label={`${product.name} - ${product.price}`}
|
<h3>{product.name}</h3>
|
||||||
>
|
<p className="text-lg font-semibold">{product.price}</p>
|
||||||
<ProductImage
|
|
||||||
imageSrc={product.imageSrc}
|
|
||||||
imageAlt={product.imageAlt || product.name}
|
|
||||||
isFavorited={product.isFavorited}
|
|
||||||
onFavoriteToggle={product.onFavorite}
|
|
||||||
showActionButton={true}
|
|
||||||
actionButtonAriaLabel={`View ${product.name} details`}
|
|
||||||
imageClassName={imageClassName}
|
|
||||||
actionButtonClassName={actionButtonClassName}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="flex flex-col gap-0 flex-1 min-w-0">
|
|
||||||
<h3 className={cls("text-base font-medium leading-[1.3]", shouldUseLightText ? "text-background" : "text-foreground", cardNameClassName)}>
|
|
||||||
{product.name}
|
|
||||||
</h3>
|
|
||||||
<p className={cls("text-sm leading-[1.3]", shouldUseLightText ? "text-background/60" : "text-foreground/60", cardVariantClassName)}>
|
|
||||||
{product.variant}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<p className={cls("text-base font-medium leading-[1.3] flex-shrink-0", shouldUseLightText ? "text-background" : "text-foreground", cardPriceClassName)}>
|
|
||||||
{product.price}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ProductCardItem.displayName = "ProductCardItem";
|
|
||||||
|
|
||||||
const ProductCardFour = ({
|
|
||||||
products: productsProp,
|
|
||||||
carouselMode = "buttons",
|
|
||||||
gridVariant,
|
|
||||||
uniformGridCustomHeightClasses = "min-h-95 2xl:min-h-105",
|
|
||||||
animationType,
|
|
||||||
title,
|
|
||||||
titleSegments,
|
|
||||||
description,
|
|
||||||
tag,
|
|
||||||
tagIcon,
|
|
||||||
tagAnimation,
|
|
||||||
buttons,
|
|
||||||
buttonAnimation,
|
|
||||||
textboxLayout,
|
|
||||||
useInvertedBackground,
|
|
||||||
ariaLabel = "Product section",
|
|
||||||
className = "",
|
|
||||||
containerClassName = "",
|
|
||||||
cardClassName = "",
|
|
||||||
imageClassName = "",
|
|
||||||
textBoxTitleClassName = "",
|
|
||||||
textBoxTitleImageWrapperClassName = "",
|
|
||||||
textBoxTitleImageClassName = "",
|
|
||||||
textBoxDescriptionClassName = "",
|
|
||||||
cardNameClassName = "",
|
|
||||||
cardPriceClassName = "",
|
|
||||||
cardVariantClassName = "",
|
|
||||||
actionButtonClassName = "",
|
|
||||||
gridClassName = "",
|
|
||||||
carouselClassName = "",
|
|
||||||
controlsClassName = "",
|
|
||||||
textBoxClassName = "",
|
|
||||||
textBoxTagClassName = "",
|
|
||||||
textBoxButtonContainerClassName = "",
|
|
||||||
textBoxButtonClassName = "",
|
|
||||||
textBoxButtonTextClassName = "",
|
|
||||||
}: ProductCardFourProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const router = useRouter();
|
|
||||||
const { products: fetchedProducts, isLoading } = useProducts();
|
|
||||||
const isFromApi = fetchedProducts.length > 0;
|
|
||||||
const products = (isFromApi ? fetchedProducts : productsProp) as ProductCard[];
|
|
||||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
|
||||||
|
|
||||||
const handleProductClick = useCallback((product: ProductCard) => {
|
|
||||||
if (isFromApi) {
|
|
||||||
router.push(`/shop/${product.id}`);
|
|
||||||
} else {
|
|
||||||
product.onProductClick?.();
|
|
||||||
}
|
|
||||||
}, [isFromApi, router]);
|
|
||||||
|
|
||||||
|
|
||||||
if (isLoading && !productsProp) {
|
|
||||||
return (
|
|
||||||
<div className="w-content-width mx-auto py-20 text-center">
|
|
||||||
<p className="text-foreground">Loading products...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!products || products.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardStack
|
|
||||||
mode={carouselMode}
|
|
||||||
gridVariant={gridVariant}
|
|
||||||
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
|
|
||||||
animationType={animationType}
|
|
||||||
|
|
||||||
title={title}
|
|
||||||
titleSegments={titleSegments}
|
|
||||||
description={description}
|
|
||||||
tag={tag}
|
|
||||||
tagIcon={tagIcon}
|
|
||||||
tagAnimation={tagAnimation}
|
|
||||||
buttons={buttons}
|
|
||||||
buttonAnimation={buttonAnimation}
|
|
||||||
textboxLayout={textboxLayout}
|
|
||||||
useInvertedBackground={useInvertedBackground}
|
|
||||||
className={className}
|
|
||||||
containerClassName={containerClassName}
|
|
||||||
gridClassName={gridClassName}
|
|
||||||
carouselClassName={carouselClassName}
|
|
||||||
controlsClassName={controlsClassName}
|
|
||||||
textBoxClassName={textBoxClassName}
|
|
||||||
titleClassName={textBoxTitleClassName}
|
|
||||||
titleImageWrapperClassName={textBoxTitleImageWrapperClassName}
|
|
||||||
titleImageClassName={textBoxTitleImageClassName}
|
|
||||||
descriptionClassName={textBoxDescriptionClassName}
|
|
||||||
tagClassName={textBoxTagClassName}
|
|
||||||
buttonContainerClassName={textBoxButtonContainerClassName}
|
|
||||||
buttonClassName={textBoxButtonClassName}
|
|
||||||
buttonTextClassName={textBoxButtonTextClassName}
|
|
||||||
ariaLabel={ariaLabel}
|
|
||||||
>
|
|
||||||
{products?.map((product, index) => (
|
|
||||||
<ProductCardItem
|
|
||||||
key={`${product.id}-${index}`}
|
|
||||||
product={{ ...product, onProductClick: () => handleProductClick(product) }}
|
|
||||||
shouldUseLightText={shouldUseLightText}
|
|
||||||
cardClassName={cardClassName}
|
|
||||||
imageClassName={imageClassName}
|
|
||||||
cardNameClassName={cardNameClassName}
|
|
||||||
cardPriceClassName={cardPriceClassName}
|
|
||||||
cardVariantClassName={cardVariantClassName}
|
|
||||||
actionButtonClassName={actionButtonClassName}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</CardStack>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ProductCardFour.displayName = "ProductCardFour";
|
|
||||||
|
|
||||||
export default ProductCardFour;
|
export default ProductCardFour;
|
||||||
|
|||||||
@@ -1,226 +1,29 @@
|
|||||||
"use client";
|
import React from 'react';
|
||||||
|
|
||||||
import { memo, useCallback } from "react";
|
interface Product {
|
||||||
import { useRouter } from "next/navigation";
|
id: string;
|
||||||
import { ArrowUpRight } from "lucide-react";
|
name: string;
|
||||||
import CardStack from "@/components/cardStack/CardStack";
|
price: string;
|
||||||
import ProductImage from "@/components/shared/ProductImage";
|
imageSrc: string;
|
||||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
imageAlt?: string;
|
||||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
}
|
||||||
import { useProducts } from "@/hooks/useProducts";
|
|
||||||
import type { Product } from "@/lib/api/product";
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
import type { ButtonConfig, GridVariant, CardAnimationType, TitleSegment, ButtonAnimationType } from "@/components/cardStack/types";
|
|
||||||
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
|
|
||||||
|
|
||||||
type ProductCardOneGridVariant = Exclude<GridVariant, "timeline">;
|
|
||||||
|
|
||||||
type ProductCard = Product;
|
|
||||||
|
|
||||||
interface ProductCardOneProps {
|
interface ProductCardOneProps {
|
||||||
products?: ProductCard[];
|
products: Product[];
|
||||||
carouselMode?: "auto" | "buttons";
|
|
||||||
gridVariant: ProductCardOneGridVariant;
|
|
||||||
uniformGridCustomHeightClasses?: string;
|
|
||||||
animationType: CardAnimationType;
|
|
||||||
title: string;
|
|
||||||
titleSegments?: TitleSegment[];
|
|
||||||
description: string;
|
|
||||||
tag?: string;
|
|
||||||
tagIcon?: LucideIcon;
|
|
||||||
tagAnimation?: ButtonAnimationType;
|
|
||||||
buttons?: ButtonConfig[];
|
|
||||||
buttonAnimation?: ButtonAnimationType;
|
|
||||||
textboxLayout: TextboxLayout;
|
|
||||||
useInvertedBackground: InvertedBackground;
|
|
||||||
ariaLabel?: string;
|
|
||||||
className?: string;
|
|
||||||
containerClassName?: string;
|
|
||||||
cardClassName?: string;
|
|
||||||
imageClassName?: string;
|
|
||||||
textBoxTitleClassName?: string;
|
|
||||||
textBoxTitleImageWrapperClassName?: string;
|
|
||||||
textBoxTitleImageClassName?: string;
|
|
||||||
textBoxDescriptionClassName?: string;
|
|
||||||
cardNameClassName?: string;
|
|
||||||
cardPriceClassName?: string;
|
|
||||||
gridClassName?: string;
|
|
||||||
carouselClassName?: string;
|
|
||||||
controlsClassName?: string;
|
|
||||||
textBoxClassName?: string;
|
|
||||||
textBoxTagClassName?: string;
|
|
||||||
textBoxButtonContainerClassName?: string;
|
|
||||||
textBoxButtonClassName?: string;
|
|
||||||
textBoxButtonTextClassName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductCardItemProps {
|
const ProductCardOne: React.FC<ProductCardOneProps> = ({ products }) => {
|
||||||
product: ProductCard;
|
|
||||||
shouldUseLightText: boolean;
|
|
||||||
cardClassName?: string;
|
|
||||||
imageClassName?: string;
|
|
||||||
cardNameClassName?: string;
|
|
||||||
cardPriceClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProductCardItem = memo(({
|
|
||||||
product,
|
|
||||||
shouldUseLightText,
|
|
||||||
cardClassName = "",
|
|
||||||
imageClassName = "",
|
|
||||||
cardNameClassName = "",
|
|
||||||
cardPriceClassName = "",
|
|
||||||
}: ProductCardItemProps) => {
|
|
||||||
return (
|
return (
|
||||||
<article
|
<div className="grid grid-cols-1 gap-4">
|
||||||
className={cls("card group relative h-full flex flex-col gap-4 cursor-pointer p-4 rounded-theme-capped", cardClassName)}
|
{products.map((product) => (
|
||||||
onClick={product.onProductClick}
|
<div key={product.id} className="product-card">
|
||||||
role="article"
|
<img src={product.imageSrc} alt={product.imageAlt || product.name} />
|
||||||
aria-label={`${product.name} - ${product.price}`}
|
<h3>{product.name}</h3>
|
||||||
>
|
<p className="text-lg font-semibold">{product.price}</p>
|
||||||
<ProductImage
|
|
||||||
imageSrc={product.imageSrc}
|
|
||||||
imageAlt={product.imageAlt || product.name}
|
|
||||||
isFavorited={product.isFavorited}
|
|
||||||
onFavoriteToggle={product.onFavorite}
|
|
||||||
imageClassName={imageClassName}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative z-1 flex items-center justify-between gap-4">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className={cls("text-base font-medium truncate leading-[1.3]", shouldUseLightText ? "text-background" : "text-foreground", cardNameClassName)}>
|
|
||||||
{product.name}
|
|
||||||
</h3>
|
|
||||||
<p className={cls("text-2xl font-medium leading-[1.3]", shouldUseLightText ? "text-background" : "text-foreground", cardPriceClassName)}>
|
|
||||||
{product.price}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
className="relative cursor-pointer primary-button h-10 w-auto aspect-square rounded-theme flex items-center justify-center flex-shrink-0"
|
|
||||||
aria-label={`View ${product.name} details`}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<ArrowUpRight className="h-4/10 text-primary-cta-text transition-transform duration-300 group-hover:rotate-45" strokeWidth={1.5} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ProductCardItem.displayName = "ProductCardItem";
|
|
||||||
|
|
||||||
const ProductCardOne = ({
|
|
||||||
products: productsProp,
|
|
||||||
carouselMode = "buttons",
|
|
||||||
gridVariant,
|
|
||||||
uniformGridCustomHeightClasses = "min-h-95 2xl:min-h-105",
|
|
||||||
animationType,
|
|
||||||
title,
|
|
||||||
titleSegments,
|
|
||||||
description,
|
|
||||||
tag,
|
|
||||||
tagIcon,
|
|
||||||
tagAnimation,
|
|
||||||
buttons,
|
|
||||||
buttonAnimation,
|
|
||||||
textboxLayout,
|
|
||||||
useInvertedBackground,
|
|
||||||
ariaLabel = "Product section",
|
|
||||||
className = "",
|
|
||||||
containerClassName = "",
|
|
||||||
cardClassName = "",
|
|
||||||
imageClassName = "",
|
|
||||||
textBoxTitleClassName = "",
|
|
||||||
textBoxTitleImageWrapperClassName = "",
|
|
||||||
textBoxTitleImageClassName = "",
|
|
||||||
textBoxDescriptionClassName = "",
|
|
||||||
cardNameClassName = "",
|
|
||||||
cardPriceClassName = "",
|
|
||||||
gridClassName = "",
|
|
||||||
carouselClassName = "",
|
|
||||||
controlsClassName = "",
|
|
||||||
textBoxClassName = "",
|
|
||||||
textBoxTagClassName = "",
|
|
||||||
textBoxButtonContainerClassName = "",
|
|
||||||
textBoxButtonClassName = "",
|
|
||||||
textBoxButtonTextClassName = "",
|
|
||||||
}: ProductCardOneProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const router = useRouter();
|
|
||||||
const { products: fetchedProducts, isLoading } = useProducts();
|
|
||||||
const isFromApi = fetchedProducts.length > 0;
|
|
||||||
const products = isFromApi ? fetchedProducts : productsProp;
|
|
||||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
|
||||||
|
|
||||||
const handleProductClick = useCallback((product: ProductCard) => {
|
|
||||||
if (isFromApi) {
|
|
||||||
router.push(`/shop/${product.id}`);
|
|
||||||
} else {
|
|
||||||
product.onProductClick?.();
|
|
||||||
}
|
|
||||||
}, [isFromApi, router]);
|
|
||||||
|
|
||||||
if (isLoading && !productsProp) {
|
|
||||||
return (
|
|
||||||
<div className="w-content-width mx-auto py-20 text-center">
|
|
||||||
<p className="text-foreground">Loading products...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!products || products.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardStack
|
|
||||||
mode={carouselMode}
|
|
||||||
gridVariant={gridVariant}
|
|
||||||
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
|
|
||||||
animationType={animationType}
|
|
||||||
|
|
||||||
title={title}
|
|
||||||
titleSegments={titleSegments}
|
|
||||||
description={description}
|
|
||||||
tag={tag}
|
|
||||||
tagIcon={tagIcon}
|
|
||||||
tagAnimation={tagAnimation}
|
|
||||||
buttons={buttons}
|
|
||||||
buttonAnimation={buttonAnimation}
|
|
||||||
textboxLayout={textboxLayout}
|
|
||||||
useInvertedBackground={useInvertedBackground}
|
|
||||||
className={className}
|
|
||||||
containerClassName={containerClassName}
|
|
||||||
gridClassName={gridClassName}
|
|
||||||
carouselClassName={carouselClassName}
|
|
||||||
controlsClassName={controlsClassName}
|
|
||||||
textBoxClassName={textBoxClassName}
|
|
||||||
titleClassName={textBoxTitleClassName}
|
|
||||||
titleImageWrapperClassName={textBoxTitleImageWrapperClassName}
|
|
||||||
titleImageClassName={textBoxTitleImageClassName}
|
|
||||||
descriptionClassName={textBoxDescriptionClassName}
|
|
||||||
tagClassName={textBoxTagClassName}
|
|
||||||
buttonContainerClassName={textBoxButtonContainerClassName}
|
|
||||||
buttonClassName={textBoxButtonClassName}
|
|
||||||
buttonTextClassName={textBoxButtonTextClassName}
|
|
||||||
ariaLabel={ariaLabel}
|
|
||||||
>
|
|
||||||
{products?.map((product, index) => (
|
|
||||||
<ProductCardItem
|
|
||||||
key={`${product.id}-${index}`}
|
|
||||||
product={{ ...product, onProductClick: () => handleProductClick(product) }}
|
|
||||||
shouldUseLightText={shouldUseLightText}
|
|
||||||
cardClassName={cardClassName}
|
|
||||||
imageClassName={imageClassName}
|
|
||||||
cardNameClassName={cardNameClassName}
|
|
||||||
cardPriceClassName={cardPriceClassName}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</CardStack>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ProductCardOne.displayName = "ProductCardOne";
|
|
||||||
|
|
||||||
export default ProductCardOne;
|
export default ProductCardOne;
|
||||||
|
|||||||
@@ -1,283 +1,29 @@
|
|||||||
"use client";
|
import React from 'react';
|
||||||
|
|
||||||
import { memo, useState, useCallback } from "react";
|
interface Product {
|
||||||
import { useRouter } from "next/navigation";
|
id: string;
|
||||||
import { Plus, Minus } from "lucide-react";
|
name: string;
|
||||||
import CardStack from "@/components/cardStack/CardStack";
|
price: string;
|
||||||
import ProductImage from "@/components/shared/ProductImage";
|
imageSrc: string;
|
||||||
import QuantityButton from "@/components/shared/QuantityButton";
|
imageAlt?: string;
|
||||||
import Button from "@/components/button/Button";
|
}
|
||||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
|
||||||
import { useProducts } from "@/hooks/useProducts";
|
|
||||||
import { getButtonProps } from "@/lib/buttonUtils";
|
|
||||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
|
||||||
import type { Product } from "@/lib/api/product";
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
import type { ButtonConfig, ButtonAnimationType, GridVariant, CardAnimationType, TitleSegment } from "@/components/cardStack/types";
|
|
||||||
import type { CTAButtonVariant, ButtonPropsForVariant } from "@/components/button/types";
|
|
||||||
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
|
|
||||||
|
|
||||||
type ProductCardThreeGridVariant = Exclude<GridVariant, "timeline" | "items-top-row-full-width-bottom" | "full-width-top-items-bottom-row">;
|
|
||||||
|
|
||||||
type ProductCard = Product & {
|
|
||||||
onQuantityChange?: (quantity: number) => void;
|
|
||||||
initialQuantity?: number;
|
|
||||||
priceButtonProps?: Partial<ButtonPropsForVariant<CTAButtonVariant>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ProductCardThreeProps {
|
interface ProductCardThreeProps {
|
||||||
products?: ProductCard[];
|
products: Product[];
|
||||||
carouselMode?: "auto" | "buttons";
|
|
||||||
gridVariant: ProductCardThreeGridVariant;
|
|
||||||
uniformGridCustomHeightClasses?: string;
|
|
||||||
animationType: CardAnimationType;
|
|
||||||
title: string;
|
|
||||||
titleSegments?: TitleSegment[];
|
|
||||||
description: string;
|
|
||||||
tag?: string;
|
|
||||||
tagIcon?: LucideIcon;
|
|
||||||
tagAnimation?: ButtonAnimationType;
|
|
||||||
buttons?: ButtonConfig[];
|
|
||||||
buttonAnimation?: ButtonAnimationType;
|
|
||||||
textboxLayout: TextboxLayout;
|
|
||||||
useInvertedBackground: InvertedBackground;
|
|
||||||
ariaLabel?: string;
|
|
||||||
className?: string;
|
|
||||||
containerClassName?: string;
|
|
||||||
cardClassName?: string;
|
|
||||||
imageClassName?: string;
|
|
||||||
textBoxTitleClassName?: string;
|
|
||||||
textBoxTitleImageWrapperClassName?: string;
|
|
||||||
textBoxTitleImageClassName?: string;
|
|
||||||
textBoxDescriptionClassName?: string;
|
|
||||||
cardNameClassName?: string;
|
|
||||||
quantityControlsClassName?: string;
|
|
||||||
gridClassName?: string;
|
|
||||||
carouselClassName?: string;
|
|
||||||
controlsClassName?: string;
|
|
||||||
textBoxClassName?: string;
|
|
||||||
textBoxTagClassName?: string;
|
|
||||||
textBoxButtonContainerClassName?: string;
|
|
||||||
textBoxButtonClassName?: string;
|
|
||||||
textBoxButtonTextClassName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ProductCardThree: React.FC<ProductCardThreeProps> = ({ products }) => {
|
||||||
interface ProductCardItemProps {
|
|
||||||
product: ProductCard;
|
|
||||||
shouldUseLightText: boolean;
|
|
||||||
isFromApi: boolean;
|
|
||||||
onBuyClick?: (productId: string, quantity: number) => void;
|
|
||||||
cardClassName?: string;
|
|
||||||
imageClassName?: string;
|
|
||||||
cardNameClassName?: string;
|
|
||||||
quantityControlsClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProductCardItem = memo(({
|
|
||||||
product,
|
|
||||||
shouldUseLightText,
|
|
||||||
isFromApi,
|
|
||||||
onBuyClick,
|
|
||||||
cardClassName = "",
|
|
||||||
imageClassName = "",
|
|
||||||
cardNameClassName = "",
|
|
||||||
quantityControlsClassName = "",
|
|
||||||
}: ProductCardItemProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const [quantity, setQuantity] = useState(product.initialQuantity || 1);
|
|
||||||
|
|
||||||
const handleIncrement = useCallback((e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const newQuantity = quantity + 1;
|
|
||||||
setQuantity(newQuantity);
|
|
||||||
product.onQuantityChange?.(newQuantity);
|
|
||||||
}, [quantity, product]);
|
|
||||||
|
|
||||||
const handleDecrement = useCallback((e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (quantity > 1) {
|
|
||||||
const newQuantity = quantity - 1;
|
|
||||||
setQuantity(newQuantity);
|
|
||||||
product.onQuantityChange?.(newQuantity);
|
|
||||||
}
|
|
||||||
}, [quantity, product]);
|
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
if (isFromApi && onBuyClick) {
|
|
||||||
onBuyClick(product.id, quantity);
|
|
||||||
} else {
|
|
||||||
product.onProductClick?.();
|
|
||||||
}
|
|
||||||
}, [isFromApi, onBuyClick, product, quantity]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<div className="grid grid-cols-1 gap-4">
|
||||||
className={cls("card group relative h-full flex flex-col gap-4 cursor-pointer p-4 rounded-theme-capped", cardClassName)}
|
{products.map((product) => (
|
||||||
onClick={handleClick}
|
<div key={product.id} className="product-card">
|
||||||
role="article"
|
<img src={product.imageSrc} alt={product.imageAlt || product.name} />
|
||||||
aria-label={`${product.name} - ${product.price}`}
|
<h3>{product.name}</h3>
|
||||||
>
|
<p className="text-lg font-semibold">{product.price}</p>
|
||||||
<ProductImage
|
|
||||||
imageSrc={product.imageSrc}
|
|
||||||
imageAlt={product.imageAlt || product.name}
|
|
||||||
isFavorited={product.isFavorited}
|
|
||||||
onFavoriteToggle={product.onFavorite}
|
|
||||||
imageClassName={imageClassName}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative z-1 flex flex-col gap-3">
|
|
||||||
<h3 className={cls("text-xl font-medium leading-[1.15] truncate", shouldUseLightText ? "text-background" : "text-foreground", cardNameClassName)}>
|
|
||||||
{product.name}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className={cls("flex items-center gap-2", quantityControlsClassName)}>
|
|
||||||
<QuantityButton
|
|
||||||
onClick={handleDecrement}
|
|
||||||
ariaLabel="Decrease quantity"
|
|
||||||
Icon={Minus}
|
|
||||||
/>
|
|
||||||
<span className={cls("text-base font-medium min-w-[2ch] text-center leading-[1]", shouldUseLightText ? "text-background" : "text-foreground")}>
|
|
||||||
{quantity}
|
|
||||||
</span>
|
|
||||||
<QuantityButton
|
|
||||||
onClick={handleIncrement}
|
|
||||||
ariaLabel="Increase quantity"
|
|
||||||
Icon={Plus}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
|
||||||
{...getButtonProps(
|
|
||||||
{
|
|
||||||
text: product.price,
|
|
||||||
props: product.priceButtonProps,
|
|
||||||
},
|
|
||||||
0,
|
|
||||||
theme.defaultButtonVariant
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ProductCardItem.displayName = "ProductCardItem";
|
|
||||||
|
|
||||||
const ProductCardThree = ({
|
|
||||||
products: productsProp,
|
|
||||||
carouselMode = "buttons",
|
|
||||||
gridVariant,
|
|
||||||
uniformGridCustomHeightClasses = "min-h-95 2xl:min-h-105",
|
|
||||||
animationType,
|
|
||||||
title,
|
|
||||||
titleSegments,
|
|
||||||
description,
|
|
||||||
tag,
|
|
||||||
tagIcon,
|
|
||||||
tagAnimation,
|
|
||||||
buttons,
|
|
||||||
buttonAnimation,
|
|
||||||
textboxLayout,
|
|
||||||
useInvertedBackground,
|
|
||||||
ariaLabel = "Product section",
|
|
||||||
className = "",
|
|
||||||
containerClassName = "",
|
|
||||||
cardClassName = "",
|
|
||||||
imageClassName = "",
|
|
||||||
textBoxTitleClassName = "",
|
|
||||||
textBoxTitleImageWrapperClassName = "",
|
|
||||||
textBoxTitleImageClassName = "",
|
|
||||||
textBoxDescriptionClassName = "",
|
|
||||||
cardNameClassName = "",
|
|
||||||
quantityControlsClassName = "",
|
|
||||||
gridClassName = "",
|
|
||||||
carouselClassName = "",
|
|
||||||
controlsClassName = "",
|
|
||||||
textBoxClassName = "",
|
|
||||||
textBoxTagClassName = "",
|
|
||||||
textBoxButtonContainerClassName = "",
|
|
||||||
textBoxButtonClassName = "",
|
|
||||||
textBoxButtonTextClassName = "",
|
|
||||||
}: ProductCardThreeProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const router = useRouter();
|
|
||||||
const { products: fetchedProducts, isLoading } = useProducts();
|
|
||||||
const isFromApi = fetchedProducts.length > 0;
|
|
||||||
const products = (isFromApi ? fetchedProducts : productsProp) as ProductCard[];
|
|
||||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
|
||||||
|
|
||||||
const handleProductClick = useCallback((product: ProductCard) => {
|
|
||||||
if (isFromApi) {
|
|
||||||
router.push(`/shop/${product.id}`);
|
|
||||||
} else {
|
|
||||||
product.onProductClick?.();
|
|
||||||
}
|
|
||||||
}, [isFromApi, router]);
|
|
||||||
|
|
||||||
if (isLoading && !productsProp) {
|
|
||||||
return (
|
|
||||||
<div className="w-content-width mx-auto py-20 text-center">
|
|
||||||
<p className="text-foreground">Loading products...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!products || products.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardStack
|
|
||||||
useInvertedBackground={useInvertedBackground}
|
|
||||||
mode={carouselMode}
|
|
||||||
gridVariant={gridVariant}
|
|
||||||
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
|
|
||||||
animationType={animationType}
|
|
||||||
|
|
||||||
title={title}
|
|
||||||
titleSegments={titleSegments}
|
|
||||||
description={description}
|
|
||||||
tag={tag}
|
|
||||||
tagIcon={tagIcon}
|
|
||||||
tagAnimation={tagAnimation}
|
|
||||||
buttons={buttons}
|
|
||||||
buttonAnimation={buttonAnimation}
|
|
||||||
textboxLayout={textboxLayout}
|
|
||||||
className={className}
|
|
||||||
containerClassName={containerClassName}
|
|
||||||
gridClassName={gridClassName}
|
|
||||||
carouselClassName={carouselClassName}
|
|
||||||
controlsClassName={controlsClassName}
|
|
||||||
textBoxClassName={textBoxClassName}
|
|
||||||
titleClassName={textBoxTitleClassName}
|
|
||||||
titleImageWrapperClassName={textBoxTitleImageWrapperClassName}
|
|
||||||
titleImageClassName={textBoxTitleImageClassName}
|
|
||||||
descriptionClassName={textBoxDescriptionClassName}
|
|
||||||
tagClassName={textBoxTagClassName}
|
|
||||||
buttonContainerClassName={textBoxButtonContainerClassName}
|
|
||||||
buttonClassName={textBoxButtonClassName}
|
|
||||||
buttonTextClassName={textBoxButtonTextClassName}
|
|
||||||
ariaLabel={ariaLabel}
|
|
||||||
>
|
|
||||||
{products?.map((product, index) => (
|
|
||||||
<ProductCardItem
|
|
||||||
key={`${product.id}-${index}`}
|
|
||||||
product={{ ...product, onProductClick: () => handleProductClick(product) }}
|
|
||||||
shouldUseLightText={shouldUseLightText}
|
|
||||||
isFromApi={isFromApi}
|
|
||||||
cardClassName={cardClassName}
|
|
||||||
imageClassName={imageClassName}
|
|
||||||
cardNameClassName={cardNameClassName}
|
|
||||||
quantityControlsClassName={quantityControlsClassName}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</CardStack>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ProductCardThree.displayName = "ProductCardThree";
|
|
||||||
|
|
||||||
export default ProductCardThree;
|
export default ProductCardThree;
|
||||||
|
|||||||
@@ -1,267 +1,29 @@
|
|||||||
"use client";
|
import React from 'react';
|
||||||
|
|
||||||
import { memo, useCallback } from "react";
|
interface Product {
|
||||||
import { useRouter } from "next/navigation";
|
id: string;
|
||||||
import { Star } from "lucide-react";
|
name: string;
|
||||||
import CardStack from "@/components/cardStack/CardStack";
|
price: string;
|
||||||
import ProductImage from "@/components/shared/ProductImage";
|
imageSrc: string;
|
||||||
import { cls, shouldUseInvertedText } from "@/lib/utils";
|
imageAlt?: string;
|
||||||
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
|
}
|
||||||
import { useProducts } from "@/hooks/useProducts";
|
|
||||||
import type { Product } from "@/lib/api/product";
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
import type { ButtonConfig, GridVariant, CardAnimationType, TitleSegment, ButtonAnimationType } from "@/components/cardStack/types";
|
|
||||||
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
|
|
||||||
|
|
||||||
type ProductCardTwoGridVariant = Exclude<GridVariant, "timeline" | "one-large-right-three-stacked-left" | "items-top-row-full-width-bottom" | "full-width-top-items-bottom-row" | "one-large-left-three-stacked-right">;
|
|
||||||
|
|
||||||
type ProductCard = Product & {
|
|
||||||
brand: string;
|
|
||||||
rating: number;
|
|
||||||
reviewCount: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ProductCardTwoProps {
|
interface ProductCardTwoProps {
|
||||||
products?: ProductCard[];
|
products: Product[];
|
||||||
carouselMode?: "auto" | "buttons";
|
|
||||||
gridVariant: ProductCardTwoGridVariant;
|
|
||||||
uniformGridCustomHeightClasses?: string;
|
|
||||||
animationType: CardAnimationType;
|
|
||||||
title: string;
|
|
||||||
titleSegments?: TitleSegment[];
|
|
||||||
description: string;
|
|
||||||
tag?: string;
|
|
||||||
tagIcon?: LucideIcon;
|
|
||||||
tagAnimation?: ButtonAnimationType;
|
|
||||||
buttons?: ButtonConfig[];
|
|
||||||
buttonAnimation?: ButtonAnimationType;
|
|
||||||
textboxLayout: TextboxLayout;
|
|
||||||
useInvertedBackground: InvertedBackground;
|
|
||||||
ariaLabel?: string;
|
|
||||||
className?: string;
|
|
||||||
containerClassName?: string;
|
|
||||||
cardClassName?: string;
|
|
||||||
imageClassName?: string;
|
|
||||||
textBoxTitleClassName?: string;
|
|
||||||
textBoxTitleImageWrapperClassName?: string;
|
|
||||||
textBoxTitleImageClassName?: string;
|
|
||||||
textBoxDescriptionClassName?: string;
|
|
||||||
cardBrandClassName?: string;
|
|
||||||
cardNameClassName?: string;
|
|
||||||
cardPriceClassName?: string;
|
|
||||||
cardRatingClassName?: string;
|
|
||||||
actionButtonClassName?: string;
|
|
||||||
gridClassName?: string;
|
|
||||||
carouselClassName?: string;
|
|
||||||
controlsClassName?: string;
|
|
||||||
textBoxClassName?: string;
|
|
||||||
textBoxTagClassName?: string;
|
|
||||||
textBoxButtonContainerClassName?: string;
|
|
||||||
textBoxButtonClassName?: string;
|
|
||||||
textBoxButtonTextClassName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductCardItemProps {
|
const ProductCardTwo: React.FC<ProductCardTwoProps> = ({ products }) => {
|
||||||
product: ProductCard;
|
|
||||||
shouldUseLightText: boolean;
|
|
||||||
cardClassName?: string;
|
|
||||||
imageClassName?: string;
|
|
||||||
cardBrandClassName?: string;
|
|
||||||
cardNameClassName?: string;
|
|
||||||
cardPriceClassName?: string;
|
|
||||||
cardRatingClassName?: string;
|
|
||||||
actionButtonClassName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProductCardItem = memo(({
|
|
||||||
product,
|
|
||||||
shouldUseLightText,
|
|
||||||
cardClassName = "",
|
|
||||||
imageClassName = "",
|
|
||||||
cardBrandClassName = "",
|
|
||||||
cardNameClassName = "",
|
|
||||||
cardPriceClassName = "",
|
|
||||||
cardRatingClassName = "",
|
|
||||||
actionButtonClassName = "",
|
|
||||||
}: ProductCardItemProps) => {
|
|
||||||
return (
|
return (
|
||||||
<article
|
<div className="grid grid-cols-1 gap-4">
|
||||||
className={cls("card group relative h-full flex flex-col gap-4 cursor-pointer p-4 rounded-theme-capped", cardClassName)}
|
{products.map((product) => (
|
||||||
onClick={product.onProductClick}
|
<div key={product.id} className="product-card">
|
||||||
role="article"
|
<img src={product.imageSrc} alt={product.imageAlt || product.name} />
|
||||||
aria-label={`${product.brand} ${product.name} - ${product.price}`}
|
<h3>{product.name}</h3>
|
||||||
>
|
<p className="text-lg font-semibold">{product.price}</p>
|
||||||
<ProductImage
|
</div>
|
||||||
imageSrc={product.imageSrc}
|
|
||||||
imageAlt={product.imageAlt || `${product.brand} ${product.name}`}
|
|
||||||
isFavorited={product.isFavorited}
|
|
||||||
onFavoriteToggle={product.onFavorite}
|
|
||||||
showActionButton={true}
|
|
||||||
actionButtonAriaLabel={`View ${product.name} details`}
|
|
||||||
imageClassName={imageClassName}
|
|
||||||
actionButtonClassName={actionButtonClassName}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative z-1 flex-1 min-w-0 flex flex-col gap-2">
|
|
||||||
<p className={cls("text-sm leading-[1]", shouldUseLightText ? "text-background" : "text-foreground", cardBrandClassName)}>
|
|
||||||
{product.brand}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col gap-1" >
|
|
||||||
<h3 className={cls("text-xl font-medium truncate leading-[1.15]", shouldUseLightText ? "text-background" : "text-foreground", cardNameClassName)}>
|
|
||||||
{product.name}
|
|
||||||
</h3>
|
|
||||||
<div className={cls("flex items-center gap-2", cardRatingClassName)}>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{[...Array(5)].map((_, i) => (
|
|
||||||
<Star
|
|
||||||
key={i}
|
|
||||||
className={cls(
|
|
||||||
"h-4 w-auto",
|
|
||||||
i < Math.floor(product.rating)
|
|
||||||
? "text-accent fill-accent"
|
|
||||||
: "text-accent opacity-20"
|
|
||||||
)}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className={cls("text-sm leading-[1.3]", shouldUseLightText ? "text-background" : "text-foreground")}>
|
|
||||||
({product.reviewCount})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className={cls("text-2xl font-medium leading-[1.3]", shouldUseLightText ? "text-background" : "text-foreground", cardPriceClassName)}>
|
|
||||||
{product.price}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
ProductCardItem.displayName = "ProductCardItem";
|
|
||||||
|
|
||||||
const ProductCardTwo = ({
|
|
||||||
products: productsProp,
|
|
||||||
carouselMode = "buttons",
|
|
||||||
gridVariant,
|
|
||||||
uniformGridCustomHeightClasses = "min-h-95 2xl:min-h-105",
|
|
||||||
animationType,
|
|
||||||
title,
|
|
||||||
titleSegments,
|
|
||||||
description,
|
|
||||||
tag,
|
|
||||||
tagIcon,
|
|
||||||
tagAnimation,
|
|
||||||
buttons,
|
|
||||||
buttonAnimation,
|
|
||||||
textboxLayout,
|
|
||||||
useInvertedBackground,
|
|
||||||
ariaLabel = "Product section",
|
|
||||||
className = "",
|
|
||||||
containerClassName = "",
|
|
||||||
cardClassName = "",
|
|
||||||
imageClassName = "",
|
|
||||||
textBoxTitleClassName = "",
|
|
||||||
textBoxTitleImageWrapperClassName = "",
|
|
||||||
textBoxTitleImageClassName = "",
|
|
||||||
textBoxDescriptionClassName = "",
|
|
||||||
cardBrandClassName = "",
|
|
||||||
cardNameClassName = "",
|
|
||||||
cardPriceClassName = "",
|
|
||||||
cardRatingClassName = "",
|
|
||||||
actionButtonClassName = "",
|
|
||||||
gridClassName = "",
|
|
||||||
carouselClassName = "",
|
|
||||||
controlsClassName = "",
|
|
||||||
textBoxClassName = "",
|
|
||||||
textBoxTagClassName = "",
|
|
||||||
textBoxButtonContainerClassName = "",
|
|
||||||
textBoxButtonClassName = "",
|
|
||||||
textBoxButtonTextClassName = "",
|
|
||||||
}: ProductCardTwoProps) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const router = useRouter();
|
|
||||||
const { products: fetchedProducts, isLoading } = useProducts();
|
|
||||||
const isFromApi = fetchedProducts.length > 0;
|
|
||||||
const products = (fetchedProducts.length > 0 ? fetchedProducts : productsProp) as ProductCard[];
|
|
||||||
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
|
|
||||||
|
|
||||||
const handleProductClick = useCallback((product: ProductCard) => {
|
|
||||||
if (isFromApi) {
|
|
||||||
router.push(`/shop/${product.id}`);
|
|
||||||
} else {
|
|
||||||
product.onProductClick?.();
|
|
||||||
}
|
|
||||||
}, [isFromApi, router]);
|
|
||||||
|
|
||||||
const customGridRows = (gridVariant === "bento-grid" || gridVariant === "bento-grid-inverted")
|
|
||||||
? "md:grid-rows-[22rem_22rem] 2xl:grid-rows-[26rem_26rem]"
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (isLoading && !productsProp) {
|
|
||||||
return (
|
|
||||||
<div className="w-content-width mx-auto py-20 text-center">
|
|
||||||
<p className="text-foreground">Loading products...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!products || products.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardStack
|
|
||||||
useInvertedBackground={useInvertedBackground}
|
|
||||||
mode={carouselMode}
|
|
||||||
gridVariant={gridVariant}
|
|
||||||
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
|
|
||||||
gridRowsClassName={customGridRows}
|
|
||||||
animationType={animationType}
|
|
||||||
|
|
||||||
title={title}
|
|
||||||
titleSegments={titleSegments}
|
|
||||||
description={description}
|
|
||||||
tag={tag}
|
|
||||||
tagIcon={tagIcon}
|
|
||||||
tagAnimation={tagAnimation}
|
|
||||||
buttons={buttons}
|
|
||||||
buttonAnimation={buttonAnimation}
|
|
||||||
textboxLayout={textboxLayout}
|
|
||||||
className={className}
|
|
||||||
containerClassName={containerClassName}
|
|
||||||
gridClassName={gridClassName}
|
|
||||||
carouselClassName={carouselClassName}
|
|
||||||
controlsClassName={controlsClassName}
|
|
||||||
textBoxClassName={textBoxClassName}
|
|
||||||
titleClassName={textBoxTitleClassName}
|
|
||||||
titleImageWrapperClassName={textBoxTitleImageWrapperClassName}
|
|
||||||
titleImageClassName={textBoxTitleImageClassName}
|
|
||||||
descriptionClassName={textBoxDescriptionClassName}
|
|
||||||
tagClassName={textBoxTagClassName}
|
|
||||||
buttonContainerClassName={textBoxButtonContainerClassName}
|
|
||||||
buttonClassName={textBoxButtonClassName}
|
|
||||||
buttonTextClassName={textBoxButtonTextClassName}
|
|
||||||
ariaLabel={ariaLabel}
|
|
||||||
>
|
|
||||||
{products?.map((product, index) => (
|
|
||||||
<ProductCardItem
|
|
||||||
key={`${product.id}-${index}`}
|
|
||||||
product={{ ...product, onProductClick: () => handleProductClick(product) }}
|
|
||||||
shouldUseLightText={shouldUseLightText}
|
|
||||||
cardClassName={cardClassName}
|
|
||||||
imageClassName={imageClassName}
|
|
||||||
cardBrandClassName={cardBrandClassName}
|
|
||||||
cardNameClassName={cardNameClassName}
|
|
||||||
cardPriceClassName={cardPriceClassName}
|
|
||||||
cardRatingClassName={cardRatingClassName}
|
|
||||||
actionButtonClassName={actionButtonClassName}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</CardStack>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ProductCardTwo.displayName = "ProductCardTwo";
|
|
||||||
|
|
||||||
export default ProductCardTwo;
|
export default ProductCardTwo;
|
||||||
|
|||||||
@@ -1,117 +1,75 @@
|
|||||||
"use client";
|
import { useState, useCallback } from "react";
|
||||||
|
|
||||||
import { useState } from "react";
|
interface CheckoutItem {
|
||||||
import { Product } from "@/lib/api/product";
|
id: string;
|
||||||
|
name: string;
|
||||||
export type CheckoutItem = {
|
price: number;
|
||||||
productId: string;
|
|
||||||
quantity: number;
|
quantity: number;
|
||||||
imageSrc?: string;
|
|
||||||
imageAlt?: string;
|
|
||||||
metadata?: {
|
|
||||||
brand?: string;
|
|
||||||
variant?: string;
|
|
||||||
rating?: number;
|
|
||||||
reviewCount?: string;
|
|
||||||
[key: string]: string | number | undefined;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CheckoutResult = {
|
|
||||||
success: boolean;
|
|
||||||
url?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useCheckout() {
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const checkout = async (items: CheckoutItem[], options?: { successUrl?: string; cancelUrl?: string }): Promise<CheckoutResult> => {
|
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
|
||||||
const projectId = process.env.NEXT_PUBLIC_PROJECT_ID;
|
|
||||||
|
|
||||||
if (!apiUrl || !projectId) {
|
|
||||||
const errorMsg = "NEXT_PUBLIC_API_URL or NEXT_PUBLIC_PROJECT_ID not configured";
|
|
||||||
setError(errorMsg);
|
|
||||||
return { success: false, error: errorMsg };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
interface CheckoutState {
|
||||||
setError(null);
|
items: CheckoutItem[];
|
||||||
|
total: number;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const useCheckout = () => {
|
||||||
|
const [state, setState] = useState<CheckoutState>({
|
||||||
const response = await fetch(`${apiUrl}/stripe/project/checkout-session`, {
|
items: [],
|
||||||
method: "POST",
|
total: 0,
|
||||||
headers: {
|
loading: false,
|
||||||
"Content-Type": "application/json",
|
error: null,
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
projectId,
|
|
||||||
items,
|
|
||||||
successUrl: options?.successUrl || window.location.href,
|
|
||||||
cancelUrl: options?.cancelUrl || window.location.href,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
const addItem = useCallback((item: CheckoutItem) => {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
setState((prev) => ({
|
||||||
const errorMsg = errorData.message || `Request failed with status ${response.status}`;
|
...prev,
|
||||||
setError(errorMsg);
|
items: [...prev.items, item],
|
||||||
return { success: false, error: errorMsg };
|
total: prev.total + item.price * item.quantity,
|
||||||
}
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const data = await response.json();
|
const removeItem = useCallback((itemId: string) => {
|
||||||
|
setState((prev) => {
|
||||||
|
const item = prev.items.find((i) => i.id === itemId);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
items: prev.items.filter((i) => i.id !== itemId),
|
||||||
|
total: prev.total - (item ? item.price * item.quantity : 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (data.data.url) {
|
const clearCart = useCallback(() => {
|
||||||
window.location.href = data.data.url;
|
setState({
|
||||||
}
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return { success: true, url: data.data.url };
|
const checkout = useCallback(async () => {
|
||||||
|
setState((prev) => ({ ...prev, loading: true, error: null }));
|
||||||
|
try {
|
||||||
|
console.log("Processing checkout with items:", state.items);
|
||||||
|
setState((prev) => ({ ...prev, loading: false }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMsg = err instanceof Error ? err.message : "Failed to create checkout session";
|
setState((prev) => ({
|
||||||
setError(errorMsg);
|
...prev,
|
||||||
return { success: false, error: errorMsg };
|
loading: false,
|
||||||
} finally {
|
error: err instanceof Error ? err.message : "Checkout failed"}));
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
}, [state.items]);
|
||||||
|
|
||||||
const buyNow = async (product: Product | string, quantity: number = 1): Promise<CheckoutResult> => {
|
|
||||||
const successUrl = new URL(window.location.href);
|
|
||||||
successUrl.searchParams.set("success", "true");
|
|
||||||
|
|
||||||
if (typeof product === "string") {
|
|
||||||
return checkout([{ productId: product, quantity }], { successUrl: successUrl.toString() });
|
|
||||||
}
|
|
||||||
|
|
||||||
let metadata: CheckoutItem["metadata"] = {};
|
|
||||||
|
|
||||||
if (product.metadata && Object.keys(product.metadata).length > 0) {
|
|
||||||
const { imageSrc, imageAlt, images, ...restMetadata } = product.metadata;
|
|
||||||
metadata = restMetadata;
|
|
||||||
} else {
|
|
||||||
if (product.brand) metadata.brand = product.brand;
|
|
||||||
if (product.variant) metadata.variant = product.variant;
|
|
||||||
if (product.rating !== undefined) metadata.rating = product.rating;
|
|
||||||
if (product.reviewCount) metadata.reviewCount = product.reviewCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
return checkout([{
|
|
||||||
productId: product.id,
|
|
||||||
quantity,
|
|
||||||
imageSrc: product.imageSrc,
|
|
||||||
imageAlt: product.imageAlt,
|
|
||||||
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
|
||||||
}], { successUrl: successUrl.toString() });
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...state,
|
||||||
|
addItem,
|
||||||
|
removeItem,
|
||||||
|
clearCart,
|
||||||
checkout,
|
checkout,
|
||||||
buyNow,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
clearError: () => setError(null),
|
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default useCheckout;
|
||||||
|
|||||||
@@ -1,45 +1,38 @@
|
|||||||
"use client";
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
interface Product {
|
||||||
import { Product, fetchProduct } from "@/lib/api/product";
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
imageSrc: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function useProduct(productId: string) {
|
export function useProduct(productId: string) {
|
||||||
const [product, setProduct] = useState<Product | null>(null);
|
const [product, setProduct] = useState<Product | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
const fetchProduct = async () => {
|
||||||
|
|
||||||
async function loadProduct() {
|
|
||||||
if (!productId) {
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setLoading(true);
|
||||||
const data = await fetchProduct(productId);
|
// Simulate fetch
|
||||||
if (isMounted) {
|
const response = await fetch(`/api/products/${productId}`);
|
||||||
|
const data = await response.json();
|
||||||
setProduct(data);
|
setProduct(data);
|
||||||
}
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isMounted) {
|
setError('Failed to fetch product');
|
||||||
setError(err instanceof Error ? err : new Error("Failed to fetch product"));
|
setProduct(null);
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (isMounted) {
|
setLoading(false);
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadProduct();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (productId) {
|
||||||
|
fetchProduct();
|
||||||
|
}
|
||||||
}, [productId]);
|
}, [productId]);
|
||||||
|
|
||||||
return { product, isLoading, error };
|
return { product, loading, error };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,115 +1,46 @@
|
|||||||
"use client";
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
import { useState, useMemo, useCallback } from "react";
|
interface CatalogProduct {
|
||||||
import { useRouter } from "next/navigation";
|
id: string;
|
||||||
import { useProducts } from "./useProducts";
|
category?: string;
|
||||||
import type { Product } from "@/lib/api/product";
|
name: string;
|
||||||
import type { CatalogProduct } from "@/components/ecommerce/productCatalog/ProductCatalogItem";
|
price: string;
|
||||||
import type { ProductVariant } from "@/components/ecommerce/productDetail/ProductDetailCard";
|
rating: number;
|
||||||
|
reviewCount?: string;
|
||||||
export type SortOption = "Newest" | "Price: Low-High" | "Price: High-Low";
|
imageSrc: string;
|
||||||
|
imageAlt?: string;
|
||||||
interface UseProductCatalogOptions {
|
|
||||||
basePath?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useProductCatalog(options: UseProductCatalogOptions = {}) {
|
const useProductCatalog = () => {
|
||||||
const { basePath = "/shop" } = options;
|
const [products, setProducts] = useState<CatalogProduct[]>([]);
|
||||||
const router = useRouter();
|
const [loading, setLoading] = useState(true);
|
||||||
const { products: fetchedProducts, isLoading } = useProducts();
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
useEffect(() => {
|
||||||
const [category, setCategory] = useState("All");
|
const fetchProducts = async () => {
|
||||||
const [sort, setSort] = useState<SortOption>("Newest");
|
try {
|
||||||
|
setLoading(true);
|
||||||
const handleProductClick = useCallback((productId: string) => {
|
const mockProducts: CatalogProduct[] = [
|
||||||
router.push(`${basePath}/${productId}`);
|
|
||||||
}, [router, basePath]);
|
|
||||||
|
|
||||||
const catalogProducts: CatalogProduct[] = useMemo(() => {
|
|
||||||
if (fetchedProducts.length === 0) return [];
|
|
||||||
|
|
||||||
return fetchedProducts.map((product) => ({
|
|
||||||
id: product.id,
|
|
||||||
name: product.name,
|
|
||||||
price: product.price,
|
|
||||||
imageSrc: product.imageSrc,
|
|
||||||
imageAlt: product.imageAlt || product.name,
|
|
||||||
rating: product.rating || 0,
|
|
||||||
reviewCount: product.reviewCount,
|
|
||||||
category: product.brand,
|
|
||||||
onProductClick: () => handleProductClick(product.id),
|
|
||||||
}));
|
|
||||||
}, [fetchedProducts, handleProductClick]);
|
|
||||||
|
|
||||||
const categories = useMemo(() => {
|
|
||||||
const categorySet = new Set<string>();
|
|
||||||
catalogProducts.forEach((product) => {
|
|
||||||
if (product.category) {
|
|
||||||
categorySet.add(product.category);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Array.from(categorySet).sort();
|
|
||||||
}, [catalogProducts]);
|
|
||||||
|
|
||||||
const filteredProducts = useMemo(() => {
|
|
||||||
let result = catalogProducts;
|
|
||||||
|
|
||||||
if (search) {
|
|
||||||
const q = search.toLowerCase();
|
|
||||||
result = result.filter(
|
|
||||||
(p) =>
|
|
||||||
p.name.toLowerCase().includes(q) ||
|
|
||||||
(p.category?.toLowerCase().includes(q) ?? false)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (category !== "All") {
|
|
||||||
result = result.filter((p) => p.category === category);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sort === "Price: Low-High") {
|
|
||||||
result = [...result].sort(
|
|
||||||
(a, b) =>
|
|
||||||
parseFloat(a.price.replace("$", "").replace(",", "")) -
|
|
||||||
parseFloat(b.price.replace("$", "").replace(",", ""))
|
|
||||||
);
|
|
||||||
} else if (sort === "Price: High-Low") {
|
|
||||||
result = [...result].sort(
|
|
||||||
(a, b) =>
|
|
||||||
parseFloat(b.price.replace("$", "").replace(",", "")) -
|
|
||||||
parseFloat(a.price.replace("$", "").replace(",", ""))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [catalogProducts, search, category, sort]);
|
|
||||||
|
|
||||||
const filters: ProductVariant[] = useMemo(() => [
|
|
||||||
{
|
{
|
||||||
label: "Category",
|
id: "1", category: "Electronics", name: "Laptop Pro", price: "$999.99", rating: 4.5,
|
||||||
options: ["All", ...categories],
|
reviewCount: "128", imageSrc: "/products/laptop.jpg", imageAlt: "Laptop Pro"},
|
||||||
selected: category,
|
|
||||||
onChange: setCategory,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "Sort",
|
id: "2", category: "Electronics", name: "Wireless Mouse", price: "$29.99", rating: 4,
|
||||||
options: ["Newest", "Price: Low-High", "Price: High-Low"] as SortOption[],
|
reviewCount: "87", imageSrc: "/products/mouse.jpg", imageAlt: "Wireless Mouse"},
|
||||||
selected: sort,
|
];
|
||||||
onChange: (value) => setSort(value as SortOption),
|
setProducts(mockProducts);
|
||||||
},
|
setError(null);
|
||||||
], [categories, category, sort]);
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to fetch products");
|
||||||
return {
|
} finally {
|
||||||
products: filteredProducts,
|
setLoading(false);
|
||||||
isLoading,
|
}
|
||||||
search,
|
|
||||||
setSearch,
|
|
||||||
category,
|
|
||||||
setCategory,
|
|
||||||
sort,
|
|
||||||
setSort,
|
|
||||||
filters,
|
|
||||||
categories,
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
fetchProducts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { products, loading, error };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useProductCatalog;
|
||||||
|
|||||||
@@ -1,196 +1,46 @@
|
|||||||
"use client";
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
import { useState, useMemo, useCallback } from "react";
|
interface ProductDetail {
|
||||||
import { useProduct } from "./useProduct";
|
id: string;
|
||||||
import type { Product } from "@/lib/api/product";
|
name: string;
|
||||||
import type { ProductVariant } from "@/components/ecommerce/productDetail/ProductDetailCard";
|
price: string;
|
||||||
import type { ExtendedCartItem } from "./useCart";
|
description: string;
|
||||||
|
imageSrc: string;
|
||||||
interface ProductImage {
|
imageAlt?: string;
|
||||||
src: string;
|
rating: number;
|
||||||
alt: string;
|
reviewCount?: string;
|
||||||
|
inStock: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductMeta {
|
const useProductDetail = (productId: string) => {
|
||||||
salePrice?: string;
|
const [product, setProduct] = useState<ProductDetail | null>(null);
|
||||||
ribbon?: string;
|
const [loading, setLoading] = useState(true);
|
||||||
inventoryStatus?: string;
|
const [error, setError] = useState<string | null>(null);
|
||||||
inventoryQuantity?: number;
|
|
||||||
sku?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useProductDetail(productId: string) {
|
useEffect(() => {
|
||||||
const { product, isLoading, error } = useProduct(productId);
|
const fetchProduct = async () => {
|
||||||
const [selectedQuantity, setSelectedQuantity] = useState(1);
|
|
||||||
const [selectedVariants, setSelectedVariants] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
const images = useMemo<ProductImage[]>(() => {
|
|
||||||
if (!product) return [];
|
|
||||||
|
|
||||||
if (product.images && product.images.length > 0) {
|
|
||||||
return product.images.map((src, index) => ({
|
|
||||||
src,
|
|
||||||
alt: product.imageAlt || `${product.name} - Image ${index + 1}`,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return [{
|
|
||||||
src: product.imageSrc,
|
|
||||||
alt: product.imageAlt || product.name,
|
|
||||||
}];
|
|
||||||
}, [product]);
|
|
||||||
|
|
||||||
const meta = useMemo<ProductMeta>(() => {
|
|
||||||
if (!product?.metadata) return {};
|
|
||||||
|
|
||||||
const metadata = product.metadata;
|
|
||||||
|
|
||||||
let salePrice: string | undefined;
|
|
||||||
const onSaleValue = metadata.onSale;
|
|
||||||
const onSale = String(onSaleValue) === "true" || onSaleValue === 1 || String(onSaleValue) === "1";
|
|
||||||
const salePriceValue = metadata.salePrice;
|
|
||||||
|
|
||||||
if (onSale && salePriceValue !== undefined && salePriceValue !== null) {
|
|
||||||
if (typeof salePriceValue === 'number') {
|
|
||||||
salePrice = `$${salePriceValue.toFixed(2)}`;
|
|
||||||
} else {
|
|
||||||
const salePriceStr = String(salePriceValue);
|
|
||||||
salePrice = salePriceStr.startsWith('$') ? salePriceStr : `$${salePriceStr}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let inventoryQuantity: number | undefined;
|
|
||||||
if (metadata.inventoryQuantity !== undefined) {
|
|
||||||
const qty = metadata.inventoryQuantity;
|
|
||||||
inventoryQuantity = typeof qty === 'number' ? qty : parseInt(String(qty), 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
salePrice,
|
|
||||||
ribbon: metadata.ribbon ? String(metadata.ribbon) : undefined,
|
|
||||||
inventoryStatus: metadata.inventoryStatus ? String(metadata.inventoryStatus) : undefined,
|
|
||||||
inventoryQuantity,
|
|
||||||
sku: metadata.sku ? String(metadata.sku) : undefined,
|
|
||||||
};
|
|
||||||
}, [product]);
|
|
||||||
|
|
||||||
const variants = useMemo<ProductVariant[]>(() => {
|
|
||||||
if (!product) return [];
|
|
||||||
|
|
||||||
const variantList: ProductVariant[] = [];
|
|
||||||
|
|
||||||
if (product.metadata?.variantOptions) {
|
|
||||||
try {
|
try {
|
||||||
const variantOptionsStr = String(product.metadata.variantOptions);
|
setLoading(true);
|
||||||
const parsedOptions = JSON.parse(variantOptionsStr);
|
const mockProduct: ProductDetail = {
|
||||||
|
id: productId,
|
||||||
if (Array.isArray(parsedOptions)) {
|
name: "Sample Product", price: "$99.99", description: "A great product with excellent features", imageSrc: "/products/sample.jpg", imageAlt: "Sample Product", rating: 4.5,
|
||||||
parsedOptions.forEach((option: any) => {
|
reviewCount: "42", inStock: true,
|
||||||
if (option.name && option.values) {
|
|
||||||
const values = typeof option.values === 'string'
|
|
||||||
? option.values.split(',').map((v: string) => v.trim())
|
|
||||||
: Array.isArray(option.values)
|
|
||||||
? option.values.map((v: any) => String(v).trim())
|
|
||||||
: [String(option.values)];
|
|
||||||
|
|
||||||
if (values.length > 0) {
|
|
||||||
const optionLabel = option.name;
|
|
||||||
const currentSelected = selectedVariants[optionLabel] || values[0];
|
|
||||||
|
|
||||||
variantList.push({
|
|
||||||
label: optionLabel,
|
|
||||||
options: values,
|
|
||||||
selected: currentSelected,
|
|
||||||
onChange: (value) => {
|
|
||||||
setSelectedVariants((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[optionLabel]: value,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to parse variantOptions:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (variantList.length === 0 && product.brand) {
|
|
||||||
variantList.push({
|
|
||||||
label: "Brand",
|
|
||||||
options: [product.brand],
|
|
||||||
selected: product.brand,
|
|
||||||
onChange: () => { },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (variantList.length === 0 && product.variant) {
|
|
||||||
const variantOptions = product.variant.includes('/')
|
|
||||||
? product.variant.split('/').map(v => v.trim())
|
|
||||||
: [product.variant];
|
|
||||||
|
|
||||||
const variantLabel = "Variant";
|
|
||||||
const currentSelected = selectedVariants[variantLabel] || variantOptions[0];
|
|
||||||
|
|
||||||
variantList.push({
|
|
||||||
label: variantLabel,
|
|
||||||
options: variantOptions,
|
|
||||||
selected: currentSelected,
|
|
||||||
onChange: (value) => {
|
|
||||||
setSelectedVariants((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[variantLabel]: value,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return variantList;
|
|
||||||
}, [product, selectedVariants]);
|
|
||||||
|
|
||||||
const quantityVariant = useMemo<ProductVariant>(() => ({
|
|
||||||
label: "Quantity",
|
|
||||||
options: Array.from({ length: 10 }, (_, i) => String(i + 1)),
|
|
||||||
selected: String(selectedQuantity),
|
|
||||||
onChange: (value) => setSelectedQuantity(parseInt(value, 10)),
|
|
||||||
}), [selectedQuantity]);
|
|
||||||
|
|
||||||
const createCartItem = useCallback((): ExtendedCartItem | null => {
|
|
||||||
if (!product) return null;
|
|
||||||
|
|
||||||
const variantStrings = Object.entries(selectedVariants).map(
|
|
||||||
([label, value]) => `${label}: ${value}`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (variantStrings.length === 0 && product.variant) {
|
|
||||||
variantStrings.push(`Variant: ${product.variant}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const variantId = Object.values(selectedVariants).join('-') || 'default';
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `${product.id}-${variantId}-${selectedQuantity}`,
|
|
||||||
productId: product.id,
|
|
||||||
name: product.name,
|
|
||||||
variants: variantStrings,
|
|
||||||
price: product.price,
|
|
||||||
quantity: selectedQuantity,
|
|
||||||
imageSrc: product.imageSrc,
|
|
||||||
imageAlt: product.imageAlt || product.name,
|
|
||||||
};
|
|
||||||
}, [product, selectedVariants, selectedQuantity]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
product,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
images,
|
|
||||||
meta,
|
|
||||||
variants,
|
|
||||||
quantityVariant,
|
|
||||||
selectedQuantity,
|
|
||||||
selectedVariants,
|
|
||||||
createCartItem,
|
|
||||||
};
|
};
|
||||||
|
setProduct(mockProduct);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to fetch product");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (productId) {
|
||||||
|
fetchProduct();
|
||||||
|
}
|
||||||
|
}, [productId]);
|
||||||
|
|
||||||
|
return { product, loading, error };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useProductDetail;
|
||||||
|
|||||||
@@ -1,39 +1,36 @@
|
|||||||
"use client";
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
interface Product {
|
||||||
import { Product, fetchProducts } from "@/lib/api/product";
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
imageSrc: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function useProducts() {
|
export function useProducts() {
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
const fetchProducts = async () => {
|
||||||
|
|
||||||
async function loadProducts() {
|
|
||||||
try {
|
try {
|
||||||
const data = await fetchProducts();
|
setLoading(true);
|
||||||
if (isMounted) {
|
// Simulate fetch
|
||||||
|
const response = await fetch('/api/products');
|
||||||
|
const data = await response.json();
|
||||||
setProducts(data);
|
setProducts(data);
|
||||||
}
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isMounted) {
|
setError('Failed to fetch products');
|
||||||
setError(err instanceof Error ? err : new Error("Failed to fetch products"));
|
setProducts([]);
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (isMounted) {
|
setLoading(false);
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadProducts();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fetchProducts();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { products, isLoading, error };
|
return { products, loading, error };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,219 +1,21 @@
|
|||||||
export type Product = {
|
export interface Product {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
price: string;
|
price: string;
|
||||||
imageSrc: string;
|
imageSrc: string;
|
||||||
imageAlt?: string;
|
imageAlt?: string;
|
||||||
images?: string[];
|
|
||||||
brand?: string;
|
|
||||||
variant?: string;
|
|
||||||
rating?: number;
|
|
||||||
reviewCount?: string;
|
|
||||||
description?: string;
|
description?: string;
|
||||||
priceId?: string;
|
|
||||||
metadata?: {
|
|
||||||
[key: string]: string | number | undefined;
|
|
||||||
};
|
|
||||||
onFavorite?: () => void;
|
|
||||||
onProductClick?: () => void;
|
|
||||||
isFavorited?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const defaultProducts: Product[] = [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
name: "Classic White Sneakers",
|
|
||||||
price: "$129",
|
|
||||||
brand: "Nike",
|
|
||||||
variant: "White / Size 42",
|
|
||||||
rating: 4.5,
|
|
||||||
reviewCount: "128",
|
|
||||||
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/default/placeholder3.avif",
|
|
||||||
imageAlt: "Classic white sneakers",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
name: "Leather Crossbody Bag",
|
|
||||||
price: "$89",
|
|
||||||
brand: "Coach",
|
|
||||||
variant: "Brown / Medium",
|
|
||||||
rating: 4.8,
|
|
||||||
reviewCount: "256",
|
|
||||||
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/default/placeholder4.webp",
|
|
||||||
imageAlt: "Brown leather crossbody bag",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
name: "Wireless Headphones",
|
|
||||||
price: "$199",
|
|
||||||
brand: "Sony",
|
|
||||||
variant: "Black",
|
|
||||||
rating: 4.7,
|
|
||||||
reviewCount: "512",
|
|
||||||
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/default/placeholder3.avif",
|
|
||||||
imageAlt: "Black wireless headphones",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "4",
|
|
||||||
name: "Minimalist Watch",
|
|
||||||
price: "$249",
|
|
||||||
brand: "Fossil",
|
|
||||||
variant: "Silver / 40mm",
|
|
||||||
rating: 4.6,
|
|
||||||
reviewCount: "89",
|
|
||||||
imageSrc: "https://webuild-dev.s3.eu-north-1.amazonaws.com/default/placeholder4.webp",
|
|
||||||
imageAlt: "Silver minimalist watch",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function formatPrice(amount: number, currency: string): string {
|
|
||||||
const formatter = new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: currency.toUpperCase(),
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
return formatter.format(amount / 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchProducts(): Promise<Product[]> {
|
export async function fetchProducts(): Promise<Product[]> {
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
|
||||||
const projectId = process.env.NEXT_PUBLIC_PROJECT_ID;
|
|
||||||
|
|
||||||
if (!apiUrl || !projectId) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = `${apiUrl}/stripe/project/products?projectId=${projectId}&expandDefaultPrice=true`;
|
const response = await fetch('/api/products');
|
||||||
const response = await fetch(url, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return [];
|
throw new Error('Failed to fetch products');
|
||||||
}
|
}
|
||||||
|
return response.json();
|
||||||
const resp = await response.json();
|
|
||||||
const data = resp.data.data || resp.data;
|
|
||||||
|
|
||||||
if (!Array.isArray(data) || data.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.map((product: any) => {
|
|
||||||
const metadata: Record<string, string | number | undefined> = {};
|
|
||||||
if (product.metadata && typeof product.metadata === 'object') {
|
|
||||||
Object.keys(product.metadata).forEach(key => {
|
|
||||||
const value = product.metadata[key];
|
|
||||||
if (value !== null && value !== undefined) {
|
|
||||||
const numValue = parseFloat(value);
|
|
||||||
metadata[key] = isNaN(numValue) ? value : numValue;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageSrc = product.images?.[0] || product.imageSrc || "https://webuild-dev.s3.eu-north-1.amazonaws.com/default/placeholder3.avif";
|
|
||||||
const imageAlt = product.imageAlt || product.name || "";
|
|
||||||
const images = product.images && Array.isArray(product.images) && product.images.length > 0
|
|
||||||
? product.images
|
|
||||||
: [imageSrc];
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: product.id || String(Math.random()),
|
|
||||||
name: product.name || "Untitled Product",
|
|
||||||
description: product.description || "",
|
|
||||||
price: product.default_price?.unit_amount
|
|
||||||
? formatPrice(product.default_price.unit_amount, product.default_price.currency || "usd")
|
|
||||||
: product.price || "$0",
|
|
||||||
priceId: product.default_price?.id || product.priceId,
|
|
||||||
imageSrc,
|
|
||||||
imageAlt,
|
|
||||||
images,
|
|
||||||
brand: product.metadata?.brand || product.brand || "",
|
|
||||||
variant: product.metadata?.variant || product.variant || "",
|
|
||||||
rating: product.metadata?.rating ? parseFloat(product.metadata.rating) : undefined,
|
|
||||||
reviewCount: product.metadata?.reviewCount || undefined,
|
|
||||||
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error fetching products:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchProduct(productId: string): Promise<Product | null> {
|
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
|
||||||
const projectId = process.env.NEXT_PUBLIC_PROJECT_ID;
|
|
||||||
|
|
||||||
if (!apiUrl || !projectId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = `${apiUrl}/stripe/project/products/${productId}?projectId=${projectId}&expandDefaultPrice=true`;
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resp = await response.json();
|
|
||||||
const product = resp.data?.data || resp.data || resp;
|
|
||||||
|
|
||||||
if (!product || typeof product !== 'object') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata: Record<string, string | number | undefined> = {};
|
|
||||||
if (product.metadata && typeof product.metadata === 'object') {
|
|
||||||
Object.keys(product.metadata).forEach(key => {
|
|
||||||
const value = product.metadata[key];
|
|
||||||
if (value !== null && value !== undefined && value !== '') {
|
|
||||||
const numValue = parseFloat(String(value));
|
|
||||||
metadata[key] = isNaN(numValue) ? String(value) : numValue;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let priceValue = product.price;
|
|
||||||
if (!priceValue && product.default_price?.unit_amount) {
|
|
||||||
priceValue = formatPrice(product.default_price.unit_amount, product.default_price.currency || "usd");
|
|
||||||
}
|
|
||||||
if (!priceValue) {
|
|
||||||
priceValue = "$0";
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageSrc = product.images?.[0] || product.imageSrc || "https://webuild-dev.s3.eu-north-1.amazonaws.com/default/placeholder3.avif";
|
|
||||||
const imageAlt = product.imageAlt || product.name || "";
|
|
||||||
const images = product.images && Array.isArray(product.images) && product.images.length > 0
|
|
||||||
? product.images
|
|
||||||
: [imageSrc];
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: product.id || String(Math.random()),
|
|
||||||
name: product.name || "Untitled Product",
|
|
||||||
description: product.description || "",
|
|
||||||
price: priceValue,
|
|
||||||
priceId: product.default_price?.id || product.priceId,
|
|
||||||
imageSrc,
|
|
||||||
imageAlt,
|
|
||||||
images,
|
|
||||||
brand: product.metadata?.brand || product.brand || "",
|
|
||||||
variant: product.metadata?.variant || product.variant || "",
|
|
||||||
rating: product.metadata?.rating ? parseFloat(String(product.metadata.rating)) : undefined,
|
|
||||||
reviewCount: product.metadata?.reviewCount || undefined,
|
|
||||||
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user