Merge pull request 'shiiiiit' (#1) from sz/migrate-old-to-nextjs into dev
Reviewed-on: #1
This commit is contained in:
commit
5b59266c16
4239
package-lock.json
generated
4239
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@ -9,12 +9,37 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@headlessui/react": "^2.2.9",
|
||||||
|
"@heroicons/react": "^2.2.0",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@lottiefiles/react-lottie-player": "^3.6.0",
|
||||||
|
"@react-pdf/renderer": "^4.3.0",
|
||||||
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@tailwindplus/elements": "^1.0.15",
|
||||||
|
"@tailwindui/react": "^0.1.1",
|
||||||
|
"axios": "^1.12.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"country-flag-icons": "^1.5.21",
|
||||||
|
"country-select-js": "^2.1.0",
|
||||||
|
"intl-tel-input": "^25.10.11",
|
||||||
|
"motion": "^12.23.22",
|
||||||
"next": "15.5.4",
|
"next": "15.5.4",
|
||||||
|
"pdfjs-dist": "^5.4.149",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0"
|
"react-dom": "19.1.0",
|
||||||
|
"react-hook-form": "^7.63.0",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-pdf": "^10.1.0",
|
||||||
|
"react-phone-number-input": "^3.4.12",
|
||||||
|
"react-toastify": "^11.0.5",
|
||||||
|
"winston": "^3.17.0",
|
||||||
|
"yup": "^1.7.1",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@eslint/js": "^9.36.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
@ -22,7 +47,10 @@
|
|||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.4",
|
"eslint-config-next": "15.5.4",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"globals": "^16.4.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
|
"postcss-preset-env": "^10.4.0",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/images/logos/pp_logo_gold_transparent.png
Normal file
BIN
public/images/logos/pp_logo_gold_transparent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
BIN
public/images/misc/coffeebeans_background.png
Normal file
BIN
public/images/misc/coffeebeans_background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
7
src/app/ClientWrapper.tsx
Normal file
7
src/app/ClientWrapper.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { I18nProvider } from './i18n/useTranslation';
|
||||||
|
|
||||||
|
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return <I18nProvider>{children}</I18nProvider>;
|
||||||
|
}
|
||||||
109
src/app/background/GlobalAnimatedBackground.tsx
Normal file
109
src/app/background/GlobalAnimatedBackground.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
// Utility to detect mobile devices
|
||||||
|
function isMobileDevice() {
|
||||||
|
if (typeof navigator === "undefined") return false;
|
||||||
|
return /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||||
|
navigator.userAgent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GlobalAnimatedBackground() {
|
||||||
|
// Always use dashboard style for a uniform look
|
||||||
|
const bgGradient = "linear-gradient(135deg, #1e293b 0%, #334155 100%)";
|
||||||
|
|
||||||
|
// Detect small screens (mobile/tablet)
|
||||||
|
const isMobile = isMobileDevice();
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
// Render only the static background gradient and overlay, no animation
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 0,
|
||||||
|
width: "100vw",
|
||||||
|
height: "100vh",
|
||||||
|
background: bgGradient,
|
||||||
|
transition: "background 0.5s",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-blue-900 via-blue-600 to-blue-400 opacity-80"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 0,
|
||||||
|
width: "100vw",
|
||||||
|
height: "100vh",
|
||||||
|
background: bgGradient,
|
||||||
|
transition: "background 0.5s",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{/* Overlays */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-blue-900 via-blue-600 to-blue-400 opacity-80"></div>
|
||||||
|
<div className="absolute top-10 left-10 w-64 h-1 bg-blue-300 opacity-50 animate-slide-loop"></div>
|
||||||
|
<div className="absolute bottom-20 right-20 w-48 h-1 bg-blue-200 opacity-40 animate-slide-loop"></div>
|
||||||
|
<div className="absolute top-1/3 left-1/4 w-72 h-1 bg-blue-400 opacity-30 animate-slide-loop"></div>
|
||||||
|
<div className="absolute top-16 left-1/3 w-32 h-32 bg-blue-500 rounded-full opacity-50 animate-float"></div>
|
||||||
|
<div className="absolute bottom-24 right-1/4 w-40 h-40 bg-blue-600 rounded-full opacity-40 animate-float"></div>
|
||||||
|
<div className="absolute top-1/2 left-1/2 w-24 h-24 bg-blue-700 rounded-full opacity-30 animate-float"></div>
|
||||||
|
<div className="absolute top-1/4 left-1/5 w-20 h-20 bg-blue-300 rounded-lg opacity-40 animate-float-slow"></div>
|
||||||
|
<div className="absolute bottom-1/3 right-1/3 w-28 h-28 bg-blue-400 rounded-lg opacity-30 animate-float-slow"></div>
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes float-slow {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes slide-loop {
|
||||||
|
0% {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-100vw);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-float {
|
||||||
|
animation: float 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.animate-float-slow {
|
||||||
|
animation: float-slow 8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.animate-slide-loop {
|
||||||
|
animation: slide-loop 12s linear infinite;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GlobalAnimatedBackground;
|
||||||
284
src/app/community/page.tsx
Normal file
284
src/app/community/page.tsx
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import useAuthStore from '../store/authStore'
|
||||||
|
import Header from '../components/nav/Header'
|
||||||
|
import Footer from '../components/Footer'
|
||||||
|
import {
|
||||||
|
UsersIcon,
|
||||||
|
ChatBubbleLeftRightIcon,
|
||||||
|
HeartIcon,
|
||||||
|
FireIcon,
|
||||||
|
TrophyIcon,
|
||||||
|
UserGroupIcon,
|
||||||
|
PlusIcon,
|
||||||
|
ArrowRightIcon
|
||||||
|
} from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
|
export default function CommunityPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const user = useAuthStore(state => state.user)
|
||||||
|
|
||||||
|
// Redirect if not logged in
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
}, [user, router])
|
||||||
|
|
||||||
|
// Don't render if no user
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
||||||
|
<p className="text-[#4A4A4A]">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock community data
|
||||||
|
const communityStats = [
|
||||||
|
{ label: 'Members', value: '12,487', icon: UsersIcon, color: 'text-blue-600' },
|
||||||
|
{ label: 'Active Groups', value: '156', icon: UserGroupIcon, color: 'text-green-600' },
|
||||||
|
{ label: 'Discussions', value: '3,421', icon: ChatBubbleLeftRightIcon, color: 'text-purple-600' },
|
||||||
|
{ label: 'Daily Active', value: '2,186', icon: FireIcon, color: 'text-orange-600' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const trendingGroups = [
|
||||||
|
{
|
||||||
|
name: 'Eco Warriors',
|
||||||
|
members: '1,284',
|
||||||
|
category: 'Sustainability',
|
||||||
|
image: '🌱',
|
||||||
|
description: 'Join fellow eco-enthusiasts in making the world greener'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Zero Waste Living',
|
||||||
|
members: '892',
|
||||||
|
category: 'Lifestyle',
|
||||||
|
image: '♻️',
|
||||||
|
description: 'Tips and tricks for living a zero-waste lifestyle'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Sustainable Fashion',
|
||||||
|
members: '756',
|
||||||
|
category: 'Fashion',
|
||||||
|
image: '👕',
|
||||||
|
description: 'Ethical fashion choices and sustainable brands'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Green Tech',
|
||||||
|
members: '634',
|
||||||
|
category: 'Technology',
|
||||||
|
image: '💚',
|
||||||
|
description: 'Discuss the latest in green technology and innovation'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const recentPosts = [
|
||||||
|
{
|
||||||
|
user: 'Sarah M.',
|
||||||
|
group: 'Eco Warriors',
|
||||||
|
time: '2 hours ago',
|
||||||
|
content: 'Just discovered a fantastic new way to upcycle old furniture! Has anyone tried painting with eco-friendly paints?',
|
||||||
|
likes: 23,
|
||||||
|
comments: 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
user: 'David K.',
|
||||||
|
group: 'Zero Waste Living',
|
||||||
|
time: '4 hours ago',
|
||||||
|
content: 'Week 3 of my zero-waste challenge! Managed to produce only 1 small jar of waste. Here are my top tips...',
|
||||||
|
likes: 45,
|
||||||
|
comments: 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
user: 'Maria L.',
|
||||||
|
group: 'Sustainable Fashion',
|
||||||
|
time: '6 hours ago',
|
||||||
|
content: 'Found an amazing local brand that makes clothes from recycled ocean plastic. Their quality is incredible!',
|
||||||
|
likes: 38,
|
||||||
|
comments: 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-gray-50">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Header Section */}
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||||
|
Welcome to Profit Planet Community 🌍
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||||
|
Connect with like-minded individuals, share sustainable practices, and make a positive impact together.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Community Stats */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-12">
|
||||||
|
{communityStats.map((stat, index) => (
|
||||||
|
<div key={index} className="bg-white rounded-lg p-6 shadow-sm border border-gray-200 text-center">
|
||||||
|
<stat.icon className={`h-8 w-8 ${stat.color} mx-auto mb-3`} />
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{stat.value}</p>
|
||||||
|
<p className="text-sm text-gray-600">{stat.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="lg:col-span-2 space-y-8">
|
||||||
|
{/* Trending Groups */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 flex items-center">
|
||||||
|
<TrophyIcon className="h-6 w-6 text-[#8D6B1D] mr-2" />
|
||||||
|
Trending Groups
|
||||||
|
</h2>
|
||||||
|
<button className="text-[#8D6B1D] hover:text-[#7A5E1A] text-sm font-medium flex items-center">
|
||||||
|
View All
|
||||||
|
<ArrowRightIcon className="h-4 w-4 ml-1" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{trendingGroups.map((group, index) => (
|
||||||
|
<div key={index} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="text-2xl">{group.image}</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-gray-900">{group.name}</h3>
|
||||||
|
<p className="text-xs text-[#8D6B1D] font-medium mb-1">{group.category}</p>
|
||||||
|
<p className="text-sm text-gray-600 mb-2">{group.description}</p>
|
||||||
|
<p className="text-xs text-gray-500">{group.members} members</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="w-full mt-3 px-3 py-2 bg-[#8D6B1D]/10 text-[#8D6B1D] rounded-lg text-sm font-medium hover:bg-[#8D6B1D]/20 transition-colors">
|
||||||
|
Join Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Discussions */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 flex items-center">
|
||||||
|
<ChatBubbleLeftRightIcon className="h-6 w-6 text-[#8D6B1D] mr-2" />
|
||||||
|
Recent Discussions
|
||||||
|
</h2>
|
||||||
|
<button className="text-[#8D6B1D] hover:text-[#7A5E1A] text-sm font-medium">
|
||||||
|
Start Discussion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{recentPosts.map((post, index) => (
|
||||||
|
<div key={index} className="border-b border-gray-100 pb-6 last:border-b-0">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="w-10 h-10 bg-[#8D6B1D]/20 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-sm font-semibold text-[#8D6B1D]">
|
||||||
|
{post.user.charAt(0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center space-x-2 mb-1">
|
||||||
|
<span className="font-medium text-gray-900">{post.user}</span>
|
||||||
|
<span className="text-gray-300">•</span>
|
||||||
|
<span className="text-sm text-[#8D6B1D]">{post.group}</span>
|
||||||
|
<span className="text-gray-300">•</span>
|
||||||
|
<span className="text-sm text-gray-500">{post.time}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-800 mb-3">{post.content}</p>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<button className="flex items-center space-x-1 text-gray-500 hover:text-red-500 transition-colors">
|
||||||
|
<HeartIcon className="h-4 w-4" />
|
||||||
|
<span className="text-sm">{post.likes}</span>
|
||||||
|
</button>
|
||||||
|
<button className="flex items-center space-x-1 text-gray-500 hover:text-[#8D6B1D] transition-colors">
|
||||||
|
<ChatBubbleLeftRightIcon className="h-4 w-4" />
|
||||||
|
<span className="text-sm">{post.comments}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Quick Actions</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button className="w-full flex items-center justify-center px-4 py-3 bg-[#8D6B1D] text-white rounded-lg hover:bg-[#7A5E1A] transition-colors">
|
||||||
|
<PlusIcon className="h-4 w-4 mr-2" />
|
||||||
|
Create Group
|
||||||
|
</button>
|
||||||
|
<button className="w-full flex items-center justify-center px-4 py-3 border border-[#8D6B1D] text-[#8D6B1D] rounded-lg hover:bg-[#8D6B1D]/10 transition-colors">
|
||||||
|
<ChatBubbleLeftRightIcon className="h-4 w-4 mr-2" />
|
||||||
|
Start Discussion
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/dashboard')}
|
||||||
|
className="w-full flex items-center justify-center px-4 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Back to Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* My Groups */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">My Groups</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center space-x-3 p-2 rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||||
|
<div className="text-lg">🌱</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">Eco Warriors</p>
|
||||||
|
<p className="text-xs text-gray-500">1,284 members</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3 p-2 rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||||
|
<div className="text-lg">♻️</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">Zero Waste Living</p>
|
||||||
|
<p className="text-xs text-gray-500">892 members</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Community Guidelines */}
|
||||||
|
<div className="bg-gradient-to-br from-[#8D6B1D]/10 to-[#C49225]/10 rounded-lg p-6 border border-[#8D6B1D]/20">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">Community Guidelines</h3>
|
||||||
|
<ul className="text-sm text-gray-700 space-y-1">
|
||||||
|
<li>• Be respectful and kind</li>
|
||||||
|
<li>• Stay on topic</li>
|
||||||
|
<li>• Share authentic experiences</li>
|
||||||
|
<li>• Help others learn and grow</li>
|
||||||
|
</ul>
|
||||||
|
<button className="text-xs text-[#8D6B1D] hover:underline mt-3">
|
||||||
|
Read full guidelines
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
26
src/app/components/Footer.tsx
Normal file
26
src/app/components/Footer.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
|
|
||||||
|
export default function Footer() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<footer className="bg-[#0F172A] py-4 px-6 shadow-inner">
|
||||||
|
<div className="container mx-auto flex justify-between items-center">
|
||||||
|
<div className="text-sm text-white/70">
|
||||||
|
© {new Date().getFullYear()} {t('footer.company')} - {t('footer.rights')}
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<a href="#" className="text-sm text-white/70 hover:text-[#8D6B1D] transition-colors">
|
||||||
|
{t('footer.privacy')}
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-sm text-white/70 hover:text-[#8D6B1D] transition-colors">
|
||||||
|
{t('footer.terms')}
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-sm text-white/70 hover:text-[#8D6B1D] transition-colors">
|
||||||
|
{t('footer.contact')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
src/app/components/LanguageSwitcher.tsx
Normal file
96
src/app/components/LanguageSwitcher.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
|
||||||
|
import { ChevronDownIcon } from '@heroicons/react/20/solid';
|
||||||
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
|
import { SUPPORTED_LANGUAGES, LANGUAGE_NAMES } from '../i18n/config';
|
||||||
|
|
||||||
|
interface LanguageSwitcherProps {
|
||||||
|
variant?: 'light' | 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flag Icons mit Emoji (viel sauberer als selbst gezeichnete CSS-Flaggen)
|
||||||
|
const FlagIcon = ({ countryCode, className = "size-5" }: { countryCode: string; className?: string }) => {
|
||||||
|
const flags = {
|
||||||
|
'de': '🇩🇪',
|
||||||
|
'en': '🇬🇧'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`${className} flex items-center justify-center text-base`}>
|
||||||
|
{flags[countryCode as keyof typeof flags] || '🏳️'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LanguageSwitcher({ variant = 'light' }: LanguageSwitcherProps) {
|
||||||
|
const { language, setLanguage } = useTranslation();
|
||||||
|
|
||||||
|
const getButtonStyles = () => {
|
||||||
|
if (variant === 'dark') {
|
||||||
|
return 'inline-flex w-full justify-center gap-x-1.5 rounded-md bg-white/10 px-3 py-2 text-sm font-semibold text-white inset-ring-1 inset-ring-white/5 hover:bg-white/20';
|
||||||
|
}
|
||||||
|
return 'inline-flex w-full justify-center gap-x-1.5 rounded-md bg-gray-100 px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-gray-300 hover:bg-gray-200';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMenuStyles = () => {
|
||||||
|
if (variant === 'dark') {
|
||||||
|
return 'absolute right-0 z-10 mt-2 w-48 origin-top-right divide-y divide-white/10 rounded-md bg-gray-800 outline-1 -outline-offset-1 outline-white/10 transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in';
|
||||||
|
}
|
||||||
|
return 'absolute right-0 z-10 mt-2 w-48 origin-top-right divide-y divide-gray-100 rounded-md bg-white outline-1 -outline-offset-1 outline-gray-200 transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getItemStyles = (isActive: boolean) => {
|
||||||
|
if (variant === 'dark') {
|
||||||
|
return `group flex items-center px-4 py-2 text-sm ${
|
||||||
|
isActive
|
||||||
|
? 'bg-[#8D6B1D] text-white'
|
||||||
|
: 'text-gray-300 data-focus:bg-white/5 data-focus:text-white data-focus:outline-hidden'
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
return `group flex items-center px-4 py-2 text-sm ${
|
||||||
|
isActive
|
||||||
|
? 'bg-[#8D6B1D] text-white'
|
||||||
|
: 'text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 data-focus:outline-hidden'
|
||||||
|
}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu as="div" className="relative inline-block">
|
||||||
|
<MenuButton className={getButtonStyles()}>
|
||||||
|
<FlagIcon countryCode={language} className="size-4" />
|
||||||
|
{LANGUAGE_NAMES[language]}
|
||||||
|
<ChevronDownIcon aria-hidden="true" className="-mr-1 size-5 text-gray-500" />
|
||||||
|
</MenuButton>
|
||||||
|
|
||||||
|
<MenuItems
|
||||||
|
transition
|
||||||
|
className={getMenuStyles()}
|
||||||
|
>
|
||||||
|
<div className="py-1">
|
||||||
|
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||||
|
<MenuItem key={lang}>
|
||||||
|
<button
|
||||||
|
onClick={() => setLanguage(lang)}
|
||||||
|
className={getItemStyles(language === lang)}
|
||||||
|
>
|
||||||
|
<FlagIcon
|
||||||
|
countryCode={lang}
|
||||||
|
className={`mr-3 size-5 ${
|
||||||
|
variant === 'dark'
|
||||||
|
? (language === lang ? 'opacity-100' : 'opacity-70 group-data-focus:opacity-100')
|
||||||
|
: (language === lang ? 'opacity-100' : 'opacity-80 group-data-focus:opacity-100')
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 text-left">{LANGUAGE_NAMES[lang]}</span>
|
||||||
|
{language === lang && (
|
||||||
|
<span className="ml-2 text-xs font-bold">✓</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</MenuItems>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/app/components/PageLayout.tsx
Normal file
48
src/app/components/PageLayout.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import Header from './nav/Header';
|
||||||
|
import Footer from './Footer';
|
||||||
|
import GlobalAnimatedBackground from '../background/GlobalAnimatedBackground';
|
||||||
|
|
||||||
|
// Utility to detect mobile devices
|
||||||
|
function isMobileDevice() {
|
||||||
|
if (typeof navigator === 'undefined') return false;
|
||||||
|
return /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
showHeader?: boolean;
|
||||||
|
showFooter?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageLayout({
|
||||||
|
children,
|
||||||
|
showHeader = true,
|
||||||
|
showFooter = true
|
||||||
|
}: PageLayoutProps) {
|
||||||
|
const isMobile = isMobileDevice();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen w-full flex flex-col relative overflow-x-hidden bg-white">
|
||||||
|
|
||||||
|
{showHeader && (
|
||||||
|
<div className="relative z-50 w-full">
|
||||||
|
<Header />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 relative z-10 w-full flex flex-col">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showFooter && (
|
||||||
|
<div className="relative z-20 w-full">
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/app/components/alert.tsx
Normal file
95
src/app/components/alert.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type React from 'react'
|
||||||
|
import { Text } from './text'
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
xs: 'sm:max-w-xs',
|
||||||
|
sm: 'sm:max-w-sm',
|
||||||
|
md: 'sm:max-w-md',
|
||||||
|
lg: 'sm:max-w-lg',
|
||||||
|
xl: 'sm:max-w-xl',
|
||||||
|
'2xl': 'sm:max-w-2xl',
|
||||||
|
'3xl': 'sm:max-w-3xl',
|
||||||
|
'4xl': 'sm:max-w-4xl',
|
||||||
|
'5xl': 'sm:max-w-5xl',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Alert({
|
||||||
|
size = 'md',
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: { size?: keyof typeof sizes; className?: string; children: React.ReactNode } & Omit<
|
||||||
|
Headless.DialogProps,
|
||||||
|
'as' | 'className'
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<Headless.Dialog {...props}>
|
||||||
|
<Headless.DialogBackdrop
|
||||||
|
transition
|
||||||
|
className="fixed inset-0 flex w-screen justify-center overflow-y-auto bg-zinc-950/15 px-2 py-2 transition duration-100 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:px-6 sm:py-8 lg:px-8 lg:py-16 dark:bg-zinc-950/50"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 w-screen overflow-y-auto pt-6 sm:pt-0">
|
||||||
|
<div className="grid min-h-full grid-rows-[1fr_auto_1fr] justify-items-center p-8 sm:grid-rows-[1fr_auto_3fr] sm:p-4">
|
||||||
|
<Headless.DialogPanel
|
||||||
|
transition
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
sizes[size],
|
||||||
|
'row-start-2 w-full rounded-2xl bg-white p-8 shadow-lg ring-1 ring-zinc-950/10 sm:rounded-2xl sm:p-6 dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline',
|
||||||
|
'transition duration-100 will-change-transform data-closed:opacity-0 data-enter:ease-out data-closed:data-enter:scale-95 data-leave:ease-in'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Headless.DialogPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Headless.Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.DialogTitleProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.DialogTitle
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'text-center text-base/6 font-semibold text-balance text-zinc-950 sm:text-left sm:text-sm/6 sm:text-wrap dark:text-white'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.DescriptionProps<typeof Text>, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Description
|
||||||
|
as={Text}
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'mt-2 text-center text-pretty sm:text-left')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div {...props} className={clsx(className, 'mt-4')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertActions({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'mt-6 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:mt-4 sm:flex-row sm:*:w-auto'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
src/app/components/auth-layout.tsx
Normal file
11
src/app/components/auth-layout.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import type React from 'react'
|
||||||
|
|
||||||
|
export function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-dvh flex-col p-2">
|
||||||
|
<div className="flex grow items-center justify-center p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-900 dark:lg:ring-white/10">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
87
src/app/components/avatar.tsx
Normal file
87
src/app/components/avatar.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React, { forwardRef } from 'react'
|
||||||
|
import { TouchTarget } from './button'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
type AvatarProps = {
|
||||||
|
src?: string | null
|
||||||
|
square?: boolean
|
||||||
|
initials?: string
|
||||||
|
alt?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({
|
||||||
|
src = null,
|
||||||
|
square = false,
|
||||||
|
initials,
|
||||||
|
alt = '',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AvatarProps & React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Basic layout
|
||||||
|
'inline-grid shrink-0 align-middle [--avatar-radius:20%] *:col-start-1 *:row-start-1',
|
||||||
|
'outline -outline-offset-1 outline-black/10 dark:outline-white/10',
|
||||||
|
// Border radius
|
||||||
|
square ? 'rounded-(--avatar-radius) *:rounded-(--avatar-radius)' : 'rounded-full *:rounded-full'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{initials && (
|
||||||
|
<svg
|
||||||
|
className="size-full fill-current p-[5%] text-[48px] font-medium uppercase select-none"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
aria-hidden={alt ? undefined : 'true'}
|
||||||
|
>
|
||||||
|
{alt && <title>{alt}</title>}
|
||||||
|
<text x="50%" y="50%" alignmentBaseline="middle" dominantBaseline="middle" textAnchor="middle" dy=".125em">
|
||||||
|
{initials}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{src && <img className="size-full" src={src} alt={alt} />}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AvatarButton = forwardRef(function AvatarButton(
|
||||||
|
{
|
||||||
|
src,
|
||||||
|
square = false,
|
||||||
|
initials,
|
||||||
|
alt,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AvatarProps &
|
||||||
|
(
|
||||||
|
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
|
||||||
|
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
|
||||||
|
),
|
||||||
|
ref: React.ForwardedRef<HTMLButtonElement>
|
||||||
|
) {
|
||||||
|
let classes = clsx(
|
||||||
|
className,
|
||||||
|
square ? 'rounded-[20%]' : 'rounded-full',
|
||||||
|
'relative inline-grid focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500'
|
||||||
|
)
|
||||||
|
|
||||||
|
return typeof props.href === 'string' ? (
|
||||||
|
<Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
|
||||||
|
<TouchTarget>
|
||||||
|
<Avatar src={src} square={square} initials={initials} alt={alt} />
|
||||||
|
</TouchTarget>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Headless.Button {...props} className={classes} ref={ref}>
|
||||||
|
<TouchTarget>
|
||||||
|
<Avatar src={src} square={square} initials={initials} alt={alt} />
|
||||||
|
</TouchTarget>
|
||||||
|
</Headless.Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
82
src/app/components/badge.tsx
Normal file
82
src/app/components/badge.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React, { forwardRef } from 'react'
|
||||||
|
import { TouchTarget } from './button'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
red: 'bg-red-500/15 text-red-700 group-data-hover:bg-red-500/25 dark:bg-red-500/10 dark:text-red-400 dark:group-data-hover:bg-red-500/20',
|
||||||
|
orange:
|
||||||
|
'bg-orange-500/15 text-orange-700 group-data-hover:bg-orange-500/25 dark:bg-orange-500/10 dark:text-orange-400 dark:group-data-hover:bg-orange-500/20',
|
||||||
|
amber:
|
||||||
|
'bg-amber-400/20 text-amber-700 group-data-hover:bg-amber-400/30 dark:bg-amber-400/10 dark:text-amber-400 dark:group-data-hover:bg-amber-400/15',
|
||||||
|
yellow:
|
||||||
|
'bg-yellow-400/20 text-yellow-700 group-data-hover:bg-yellow-400/30 dark:bg-yellow-400/10 dark:text-yellow-300 dark:group-data-hover:bg-yellow-400/15',
|
||||||
|
lime: 'bg-lime-400/20 text-lime-700 group-data-hover:bg-lime-400/30 dark:bg-lime-400/10 dark:text-lime-300 dark:group-data-hover:bg-lime-400/15',
|
||||||
|
green:
|
||||||
|
'bg-green-500/15 text-green-700 group-data-hover:bg-green-500/25 dark:bg-green-500/10 dark:text-green-400 dark:group-data-hover:bg-green-500/20',
|
||||||
|
emerald:
|
||||||
|
'bg-emerald-500/15 text-emerald-700 group-data-hover:bg-emerald-500/25 dark:bg-emerald-500/10 dark:text-emerald-400 dark:group-data-hover:bg-emerald-500/20',
|
||||||
|
teal: 'bg-teal-500/15 text-teal-700 group-data-hover:bg-teal-500/25 dark:bg-teal-500/10 dark:text-teal-300 dark:group-data-hover:bg-teal-500/20',
|
||||||
|
cyan: 'bg-cyan-400/20 text-cyan-700 group-data-hover:bg-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-300 dark:group-data-hover:bg-cyan-400/15',
|
||||||
|
sky: 'bg-sky-500/15 text-sky-700 group-data-hover:bg-sky-500/25 dark:bg-sky-500/10 dark:text-sky-300 dark:group-data-hover:bg-sky-500/20',
|
||||||
|
blue: 'bg-blue-500/15 text-blue-700 group-data-hover:bg-blue-500/25 dark:text-blue-400 dark:group-data-hover:bg-blue-500/25',
|
||||||
|
indigo:
|
||||||
|
'bg-indigo-500/15 text-indigo-700 group-data-hover:bg-indigo-500/25 dark:text-indigo-400 dark:group-data-hover:bg-indigo-500/20',
|
||||||
|
violet:
|
||||||
|
'bg-violet-500/15 text-violet-700 group-data-hover:bg-violet-500/25 dark:text-violet-400 dark:group-data-hover:bg-violet-500/20',
|
||||||
|
purple:
|
||||||
|
'bg-purple-500/15 text-purple-700 group-data-hover:bg-purple-500/25 dark:text-purple-400 dark:group-data-hover:bg-purple-500/20',
|
||||||
|
fuchsia:
|
||||||
|
'bg-fuchsia-400/15 text-fuchsia-700 group-data-hover:bg-fuchsia-400/25 dark:bg-fuchsia-400/10 dark:text-fuchsia-400 dark:group-data-hover:bg-fuchsia-400/20',
|
||||||
|
pink: 'bg-pink-400/15 text-pink-700 group-data-hover:bg-pink-400/25 dark:bg-pink-400/10 dark:text-pink-400 dark:group-data-hover:bg-pink-400/20',
|
||||||
|
rose: 'bg-rose-400/15 text-rose-700 group-data-hover:bg-rose-400/25 dark:bg-rose-400/10 dark:text-rose-400 dark:group-data-hover:bg-rose-400/20',
|
||||||
|
zinc: 'bg-zinc-600/10 text-zinc-700 group-data-hover:bg-zinc-600/20 dark:bg-white/5 dark:text-zinc-400 dark:group-data-hover:bg-white/10',
|
||||||
|
}
|
||||||
|
|
||||||
|
type BadgeProps = { color?: keyof typeof colors }
|
||||||
|
|
||||||
|
export function Badge({ color = 'zinc', className, ...props }: BadgeProps & React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'inline-flex items-center gap-x-1.5 rounded-md px-1.5 py-0.5 text-sm/5 font-medium sm:text-xs/5 forced-colors:outline',
|
||||||
|
colors[color]
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BadgeButton = forwardRef(function BadgeButton(
|
||||||
|
{
|
||||||
|
color = 'zinc',
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: BadgeProps & { className?: string; children: React.ReactNode } & (
|
||||||
|
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
|
||||||
|
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
|
||||||
|
),
|
||||||
|
ref: React.ForwardedRef<HTMLElement>
|
||||||
|
) {
|
||||||
|
let classes = clsx(
|
||||||
|
className,
|
||||||
|
'group relative inline-flex rounded-md focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500'
|
||||||
|
)
|
||||||
|
|
||||||
|
return typeof props.href === 'string' ? (
|
||||||
|
<Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
|
||||||
|
<TouchTarget>
|
||||||
|
<Badge color={color}>{children}</Badge>
|
||||||
|
</TouchTarget>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Headless.Button {...props} className={classes} ref={ref}>
|
||||||
|
<TouchTarget>
|
||||||
|
<Badge color={color}>{children}</Badge>
|
||||||
|
</TouchTarget>
|
||||||
|
</Headless.Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
204
src/app/components/button.tsx
Normal file
204
src/app/components/button.tsx
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React, { forwardRef } from 'react'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
base: [
|
||||||
|
// Base
|
||||||
|
'relative isolate inline-flex items-baseline justify-center gap-x-2 rounded-lg border text-base/6 font-semibold',
|
||||||
|
// Sizing
|
||||||
|
'px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/6',
|
||||||
|
// Focus
|
||||||
|
'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500',
|
||||||
|
// Disabled
|
||||||
|
'data-disabled:opacity-50',
|
||||||
|
// Icon
|
||||||
|
'*:data-[slot=icon]:-mx-0.5 *:data-[slot=icon]:my-0.5 *:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-(--btn-icon) sm:*:data-[slot=icon]:my-1 sm:*:data-[slot=icon]:size-4 forced-colors:[--btn-icon:ButtonText] forced-colors:data-hover:[--btn-icon:ButtonText]',
|
||||||
|
],
|
||||||
|
solid: [
|
||||||
|
// Optical border, implemented as the button background to avoid corner artifacts
|
||||||
|
'border-transparent bg-(--btn-border)',
|
||||||
|
// Dark mode: border is rendered on `after` so background is set to button background
|
||||||
|
'dark:bg-(--btn-bg)',
|
||||||
|
// Button background, implemented as foreground layer to stack on top of pseudo-border layer
|
||||||
|
'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(var(--radius-lg)-1px)] before:bg-(--btn-bg)',
|
||||||
|
// Drop shadow, applied to the inset `before` layer so it blends with the border
|
||||||
|
'before:shadow-sm',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Dark mode: Subtle white outline is applied using a border
|
||||||
|
'dark:border-white/5',
|
||||||
|
// Shim/overlay, inset to match button foreground and used for hover state + highlight shadow
|
||||||
|
'after:absolute after:inset-0 after:-z-10 after:rounded-[calc(var(--radius-lg)-1px)]',
|
||||||
|
// Inner highlight shadow
|
||||||
|
'after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
|
||||||
|
// White overlay on hover
|
||||||
|
'data-active:after:bg-(--btn-hover-overlay) data-hover:after:bg-(--btn-hover-overlay)',
|
||||||
|
// Dark mode: `after` layer expands to cover entire button
|
||||||
|
'dark:after:-inset-px dark:after:rounded-lg',
|
||||||
|
// Disabled
|
||||||
|
'data-disabled:before:shadow-none data-disabled:after:shadow-none',
|
||||||
|
],
|
||||||
|
outline: [
|
||||||
|
// Base
|
||||||
|
'border-zinc-950/10 text-zinc-950 data-active:bg-zinc-950/2.5 data-hover:bg-zinc-950/2.5',
|
||||||
|
// Dark mode
|
||||||
|
'dark:border-white/15 dark:text-white dark:[--btn-bg:transparent] dark:data-active:bg-white/5 dark:data-hover:bg-white/5',
|
||||||
|
// Icon
|
||||||
|
'[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
|
||||||
|
],
|
||||||
|
plain: [
|
||||||
|
// Base
|
||||||
|
'border-transparent text-zinc-950 data-active:bg-zinc-950/5 data-hover:bg-zinc-950/5',
|
||||||
|
// Dark mode
|
||||||
|
'dark:text-white dark:data-active:bg-white/10 dark:data-hover:bg-white/10',
|
||||||
|
// Icon
|
||||||
|
'[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
'dark/zinc': [
|
||||||
|
'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
|
||||||
|
'dark:text-white dark:[--btn-bg:var(--color-zinc-600)] dark:[--btn-hover-overlay:var(--color-white)]/5',
|
||||||
|
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
|
||||||
|
],
|
||||||
|
light: [
|
||||||
|
'text-zinc-950 [--btn-bg:white] [--btn-border:var(--color-zinc-950)]/10 [--btn-hover-overlay:var(--color-zinc-950)]/2.5 data-active:[--btn-border:var(--color-zinc-950)]/15 data-hover:[--btn-border:var(--color-zinc-950)]/15',
|
||||||
|
'dark:text-white dark:[--btn-hover-overlay:var(--color-white)]/5 dark:[--btn-bg:var(--color-zinc-800)]',
|
||||||
|
'[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
|
||||||
|
],
|
||||||
|
'dark/white': [
|
||||||
|
'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
|
||||||
|
'dark:text-zinc-950 dark:[--btn-bg:white] dark:[--btn-hover-overlay:var(--color-zinc-950)]/5',
|
||||||
|
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
|
||||||
|
],
|
||||||
|
dark: [
|
||||||
|
'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
|
||||||
|
'dark:[--btn-hover-overlay:var(--color-white)]/5 dark:[--btn-bg:var(--color-zinc-800)]',
|
||||||
|
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
|
||||||
|
],
|
||||||
|
white: [
|
||||||
|
'text-zinc-950 [--btn-bg:white] [--btn-border:var(--color-zinc-950)]/10 [--btn-hover-overlay:var(--color-zinc-950)]/2.5 data-active:[--btn-border:var(--color-zinc-950)]/15 data-hover:[--btn-border:var(--color-zinc-950)]/15',
|
||||||
|
'dark:[--btn-hover-overlay:var(--color-zinc-950)]/5',
|
||||||
|
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-500)] data-hover:[--btn-icon:var(--color-zinc-500)]',
|
||||||
|
],
|
||||||
|
zinc: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-zinc-600)] [--btn-border:var(--color-zinc-700)]/90',
|
||||||
|
'dark:[--btn-hover-overlay:var(--color-white)]/5',
|
||||||
|
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
|
||||||
|
],
|
||||||
|
indigo: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-indigo-500)] [--btn-border:var(--color-indigo-600)]/90',
|
||||||
|
'[--btn-icon:var(--color-indigo-300)] data-active:[--btn-icon:var(--color-indigo-200)] data-hover:[--btn-icon:var(--color-indigo-200)]',
|
||||||
|
],
|
||||||
|
cyan: [
|
||||||
|
'text-cyan-950 [--btn-bg:var(--color-cyan-300)] [--btn-border:var(--color-cyan-400)]/80 [--btn-hover-overlay:var(--color-white)]/25',
|
||||||
|
'[--btn-icon:var(--color-cyan-500)]',
|
||||||
|
],
|
||||||
|
red: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-red-600)] [--btn-border:var(--color-red-700)]/90',
|
||||||
|
'[--btn-icon:var(--color-red-300)] data-active:[--btn-icon:var(--color-red-200)] data-hover:[--btn-icon:var(--color-red-200)]',
|
||||||
|
],
|
||||||
|
orange: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-orange-500)] [--btn-border:var(--color-orange-600)]/90',
|
||||||
|
'[--btn-icon:var(--color-orange-300)] data-active:[--btn-icon:var(--color-orange-200)] data-hover:[--btn-icon:var(--color-orange-200)]',
|
||||||
|
],
|
||||||
|
amber: [
|
||||||
|
'text-amber-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-amber-400)] [--btn-border:var(--color-amber-500)]/80',
|
||||||
|
'[--btn-icon:var(--color-amber-600)]',
|
||||||
|
],
|
||||||
|
yellow: [
|
||||||
|
'text-yellow-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-yellow-300)] [--btn-border:var(--color-yellow-400)]/80',
|
||||||
|
'[--btn-icon:var(--color-yellow-600)] data-active:[--btn-icon:var(--color-yellow-700)] data-hover:[--btn-icon:var(--color-yellow-700)]',
|
||||||
|
],
|
||||||
|
lime: [
|
||||||
|
'text-lime-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-lime-300)] [--btn-border:var(--color-lime-400)]/80',
|
||||||
|
'[--btn-icon:var(--color-lime-600)] data-active:[--btn-icon:var(--color-lime-700)] data-hover:[--btn-icon:var(--color-lime-700)]',
|
||||||
|
],
|
||||||
|
green: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-green-600)] [--btn-border:var(--color-green-700)]/90',
|
||||||
|
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
|
||||||
|
],
|
||||||
|
emerald: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-emerald-600)] [--btn-border:var(--color-emerald-700)]/90',
|
||||||
|
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
|
||||||
|
],
|
||||||
|
teal: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-teal-600)] [--btn-border:var(--color-teal-700)]/90',
|
||||||
|
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
|
||||||
|
],
|
||||||
|
sky: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-sky-500)] [--btn-border:var(--color-sky-600)]/80',
|
||||||
|
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
|
||||||
|
],
|
||||||
|
blue: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-blue-600)] [--btn-border:var(--color-blue-700)]/90',
|
||||||
|
'[--btn-icon:var(--color-blue-400)] data-active:[--btn-icon:var(--color-blue-300)] data-hover:[--btn-icon:var(--color-blue-300)]',
|
||||||
|
],
|
||||||
|
violet: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-violet-500)] [--btn-border:var(--color-violet-600)]/90',
|
||||||
|
'[--btn-icon:var(--color-violet-300)] data-active:[--btn-icon:var(--color-violet-200)] data-hover:[--btn-icon:var(--color-violet-200)]',
|
||||||
|
],
|
||||||
|
purple: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-purple-500)] [--btn-border:var(--color-purple-600)]/90',
|
||||||
|
'[--btn-icon:var(--color-purple-300)] data-active:[--btn-icon:var(--color-purple-200)] data-hover:[--btn-icon:var(--color-purple-200)]',
|
||||||
|
],
|
||||||
|
fuchsia: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-fuchsia-500)] [--btn-border:var(--color-fuchsia-600)]/90',
|
||||||
|
'[--btn-icon:var(--color-fuchsia-300)] data-active:[--btn-icon:var(--color-fuchsia-200)] data-hover:[--btn-icon:var(--color-fuchsia-200)]',
|
||||||
|
],
|
||||||
|
pink: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-pink-500)] [--btn-border:var(--color-pink-600)]/90',
|
||||||
|
'[--btn-icon:var(--color-pink-300)] data-active:[--btn-icon:var(--color-pink-200)] data-hover:[--btn-icon:var(--color-pink-200)]',
|
||||||
|
],
|
||||||
|
rose: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-rose-500)] [--btn-border:var(--color-rose-600)]/90',
|
||||||
|
'[--btn-icon:var(--color-rose-300)] data-active:[--btn-icon:var(--color-rose-200)] data-hover:[--btn-icon:var(--color-rose-200)]',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type ButtonProps = (
|
||||||
|
| { color?: keyof typeof styles.colors; outline?: never; plain?: never }
|
||||||
|
| { color?: never; outline: true; plain?: never }
|
||||||
|
| { color?: never; outline?: never; plain: true }
|
||||||
|
) & { className?: string; children: React.ReactNode } & (
|
||||||
|
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
|
||||||
|
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Button = forwardRef(function Button(
|
||||||
|
{ color, outline, plain, className, children, ...props }: ButtonProps,
|
||||||
|
ref: React.ForwardedRef<HTMLElement>
|
||||||
|
) {
|
||||||
|
let classes = clsx(
|
||||||
|
className,
|
||||||
|
styles.base,
|
||||||
|
outline ? styles.outline : plain ? styles.plain : clsx(styles.solid, styles.colors[color ?? 'dark/zinc'])
|
||||||
|
)
|
||||||
|
|
||||||
|
return typeof props.href === 'string' ? (
|
||||||
|
<Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
|
||||||
|
<TouchTarget>{children}</TouchTarget>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Headless.Button {...props} className={clsx(classes, 'cursor-default')} ref={ref}>
|
||||||
|
<TouchTarget>{children}</TouchTarget>
|
||||||
|
</Headless.Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand the hit area to at least 44×44px on touch devices
|
||||||
|
*/
|
||||||
|
export function TouchTarget({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="absolute top-1/2 left-1/2 size-[max(100%,2.75rem)] -translate-x-1/2 -translate-y-1/2 pointer-fine:hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
157
src/app/components/checkbox.tsx
Normal file
157
src/app/components/checkbox.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type React from 'react'
|
||||||
|
|
||||||
|
export function CheckboxGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="control"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Basic groups
|
||||||
|
'space-y-3',
|
||||||
|
// With descriptions
|
||||||
|
'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckboxField({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Field
|
||||||
|
data-slot="field"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Base layout
|
||||||
|
'grid grid-cols-[1.125rem_1fr] gap-x-4 gap-y-1 sm:grid-cols-[1rem_1fr]',
|
||||||
|
// Control layout
|
||||||
|
'*:data-[slot=control]:col-start-1 *:data-[slot=control]:row-start-1 *:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1',
|
||||||
|
// Label layout
|
||||||
|
'*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1',
|
||||||
|
// Description layout
|
||||||
|
'*:data-[slot=description]:col-start-2 *:data-[slot=description]:row-start-2',
|
||||||
|
// With description
|
||||||
|
'has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = [
|
||||||
|
// Basic layout
|
||||||
|
'relative isolate flex size-4.5 items-center justify-center rounded-[0.3125rem] sm:size-4',
|
||||||
|
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||||
|
'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(0.3125rem-1px)] before:bg-white before:shadow-sm',
|
||||||
|
// Background color when checked
|
||||||
|
'group-data-checked:before:bg-(--checkbox-checked-bg)',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Background color applied to control in dark mode
|
||||||
|
'dark:bg-white/5 dark:group-data-checked:bg-(--checkbox-checked-bg)',
|
||||||
|
// Border
|
||||||
|
'border border-zinc-950/15 group-data-checked:border-transparent group-data-hover:group-data-checked:border-transparent group-data-hover:border-zinc-950/30 group-data-checked:bg-(--checkbox-checked-border)',
|
||||||
|
'dark:border-white/15 dark:group-data-checked:border-white/5 dark:group-data-hover:group-data-checked:border-white/5 dark:group-data-hover:border-white/30',
|
||||||
|
// Inner highlight shadow
|
||||||
|
'after:absolute after:inset-0 after:rounded-[calc(0.3125rem-1px)] after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
|
||||||
|
'dark:after:-inset-px dark:after:hidden dark:after:rounded-[0.3125rem] dark:group-data-checked:after:block',
|
||||||
|
// Focus ring
|
||||||
|
'group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-blue-500',
|
||||||
|
// Disabled state
|
||||||
|
'group-data-disabled:opacity-50',
|
||||||
|
'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--checkbox-check:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent',
|
||||||
|
'dark:group-data-disabled:border-white/20 dark:group-data-disabled:bg-white/2.5 dark:group-data-disabled:[--checkbox-check:var(--color-white)]/50 dark:group-data-checked:group-data-disabled:after:hidden',
|
||||||
|
// Forced colors mode
|
||||||
|
'forced-colors:[--checkbox-check:HighlightText] forced-colors:[--checkbox-checked-bg:Highlight] forced-colors:group-data-disabled:[--checkbox-check:Highlight]',
|
||||||
|
'dark:forced-colors:[--checkbox-check:HighlightText] dark:forced-colors:[--checkbox-checked-bg:Highlight] dark:forced-colors:group-data-disabled:[--checkbox-check:Highlight]',
|
||||||
|
]
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
'dark/zinc': [
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
|
||||||
|
'dark:[--checkbox-checked-bg:var(--color-zinc-600)]',
|
||||||
|
],
|
||||||
|
'dark/white': [
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
|
||||||
|
'dark:[--checkbox-check:var(--color-zinc-900)] dark:[--checkbox-checked-bg:var(--color-white)] dark:[--checkbox-checked-border:var(--color-zinc-950)]/15',
|
||||||
|
],
|
||||||
|
white:
|
||||||
|
'[--checkbox-check:var(--color-zinc-900)] [--checkbox-checked-bg:var(--color-white)] [--checkbox-checked-border:var(--color-zinc-950)]/15',
|
||||||
|
dark: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
|
||||||
|
zinc: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-600)] [--checkbox-checked-border:var(--color-zinc-700)]/90',
|
||||||
|
red: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-red-600)] [--checkbox-checked-border:var(--color-red-700)]/90',
|
||||||
|
orange:
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-orange-500)] [--checkbox-checked-border:var(--color-orange-600)]/90',
|
||||||
|
amber:
|
||||||
|
'[--checkbox-check:var(--color-amber-950)] [--checkbox-checked-bg:var(--color-amber-400)] [--checkbox-checked-border:var(--color-amber-500)]/80',
|
||||||
|
yellow:
|
||||||
|
'[--checkbox-check:var(--color-yellow-950)] [--checkbox-checked-bg:var(--color-yellow-300)] [--checkbox-checked-border:var(--color-yellow-400)]/80',
|
||||||
|
lime: '[--checkbox-check:var(--color-lime-950)] [--checkbox-checked-bg:var(--color-lime-300)] [--checkbox-checked-border:var(--color-lime-400)]/80',
|
||||||
|
green:
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-green-600)] [--checkbox-checked-border:var(--color-green-700)]/90',
|
||||||
|
emerald:
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-emerald-600)] [--checkbox-checked-border:var(--color-emerald-700)]/90',
|
||||||
|
teal: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-teal-600)] [--checkbox-checked-border:var(--color-teal-700)]/90',
|
||||||
|
cyan: '[--checkbox-check:var(--color-cyan-950)] [--checkbox-checked-bg:var(--color-cyan-300)] [--checkbox-checked-border:var(--color-cyan-400)]/80',
|
||||||
|
sky: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-sky-500)] [--checkbox-checked-border:var(--color-sky-600)]/80',
|
||||||
|
blue: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-blue-600)] [--checkbox-checked-border:var(--color-blue-700)]/90',
|
||||||
|
indigo:
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-indigo-500)] [--checkbox-checked-border:var(--color-indigo-600)]/90',
|
||||||
|
violet:
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-violet-500)] [--checkbox-checked-border:var(--color-violet-600)]/90',
|
||||||
|
purple:
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-purple-500)] [--checkbox-checked-border:var(--color-purple-600)]/90',
|
||||||
|
fuchsia:
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-fuchsia-500)] [--checkbox-checked-border:var(--color-fuchsia-600)]/90',
|
||||||
|
pink: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-pink-500)] [--checkbox-checked-border:var(--color-pink-600)]/90',
|
||||||
|
rose: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-rose-500)] [--checkbox-checked-border:var(--color-rose-600)]/90',
|
||||||
|
}
|
||||||
|
|
||||||
|
type Color = keyof typeof colors
|
||||||
|
|
||||||
|
export function Checkbox({
|
||||||
|
color = 'dark/zinc',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
color?: Color
|
||||||
|
className?: string
|
||||||
|
} & Omit<Headless.CheckboxProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Checkbox
|
||||||
|
data-slot="control"
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'group inline-flex focus:outline-hidden')}
|
||||||
|
>
|
||||||
|
<span className={clsx([base, colors[color]])}>
|
||||||
|
<svg
|
||||||
|
className="size-4 stroke-(--checkbox-check) opacity-0 group-data-checked:opacity-100 sm:h-3.5 sm:w-3.5"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
{/* Checkmark icon */}
|
||||||
|
<path
|
||||||
|
className="opacity-100 group-data-indeterminate:opacity-0"
|
||||||
|
d="M3 8L6 11L11 3.5"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
{/* Indeterminate icon */}
|
||||||
|
<path
|
||||||
|
className="opacity-0 group-data-indeterminate:opacity-100"
|
||||||
|
d="M3 7H11"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</Headless.Checkbox>
|
||||||
|
)
|
||||||
|
}
|
||||||
188
src/app/components/combobox.tsx
Normal file
188
src/app/components/combobox.tsx
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export function Combobox<T>({
|
||||||
|
options,
|
||||||
|
displayValue,
|
||||||
|
filter,
|
||||||
|
anchor = 'bottom',
|
||||||
|
className,
|
||||||
|
placeholder,
|
||||||
|
autoFocus,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
options: T[]
|
||||||
|
displayValue: (value: T | null) => string | undefined
|
||||||
|
filter?: (value: T, query: string) => boolean
|
||||||
|
className?: string
|
||||||
|
placeholder?: string
|
||||||
|
autoFocus?: boolean
|
||||||
|
'aria-label'?: string
|
||||||
|
children: (value: NonNullable<T>) => React.ReactElement
|
||||||
|
} & Omit<Headless.ComboboxProps<T, false>, 'as' | 'multiple' | 'children'> & { anchor?: 'top' | 'bottom' }) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
|
const filteredOptions =
|
||||||
|
query === ''
|
||||||
|
? options
|
||||||
|
: options.filter((option) =>
|
||||||
|
filter ? filter(option, query) : displayValue(option)?.toLowerCase().includes(query.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Headless.Combobox {...props} multiple={false} virtual={{ options: filteredOptions }} onClose={() => setQuery('')}>
|
||||||
|
<span
|
||||||
|
data-slot="control"
|
||||||
|
className={clsx([
|
||||||
|
className,
|
||||||
|
// Basic layout
|
||||||
|
'relative block w-full',
|
||||||
|
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||||
|
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Focus ring
|
||||||
|
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
|
||||||
|
// Disabled state
|
||||||
|
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
|
||||||
|
// Invalid state
|
||||||
|
'has-data-invalid:before:shadow-red-500/10',
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
<Headless.ComboboxInput
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
data-slot="control"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
displayValue={(option: T) => displayValue(option) ?? ''}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={clsx([
|
||||||
|
className,
|
||||||
|
// Basic layout
|
||||||
|
'relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
|
||||||
|
// Horizontal padding
|
||||||
|
'pr-[calc(--spacing(10)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pr-[calc(--spacing(9)-1px)] sm:pl-[calc(--spacing(3)-1px)]',
|
||||||
|
// Typography
|
||||||
|
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white',
|
||||||
|
// Border
|
||||||
|
'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
|
||||||
|
// Background color
|
||||||
|
'bg-transparent dark:bg-white/5',
|
||||||
|
// Hide default focus styles
|
||||||
|
'focus:outline-hidden',
|
||||||
|
// Invalid state
|
||||||
|
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-500 dark:data-invalid:data-hover:border-red-500',
|
||||||
|
// Disabled state
|
||||||
|
'data-disabled:border-zinc-950/20 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/2.5 dark:data-hover:data-disabled:border-white/15',
|
||||||
|
// System icons
|
||||||
|
'dark:scheme-dark',
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
<Headless.ComboboxButton className="group absolute inset-y-0 right-0 flex items-center px-2">
|
||||||
|
<svg
|
||||||
|
className="size-5 stroke-zinc-500 group-data-disabled:stroke-zinc-600 group-data-hover:stroke-zinc-700 sm:size-4 dark:stroke-zinc-400 dark:group-data-hover:stroke-zinc-300 forced-colors:stroke-[CanvasText]"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path d="M5.75 10.75L8 13L10.25 10.75" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M10.25 5.25L8 3L5.75 5.25" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</Headless.ComboboxButton>
|
||||||
|
</span>
|
||||||
|
<Headless.ComboboxOptions
|
||||||
|
transition
|
||||||
|
anchor={anchor}
|
||||||
|
className={clsx(
|
||||||
|
// Anchor positioning
|
||||||
|
'[--anchor-gap:--spacing(2)] [--anchor-padding:--spacing(4)] sm:data-[anchor~=start]:[--anchor-offset:-4px]',
|
||||||
|
// Base styles,
|
||||||
|
'isolate min-w-[calc(var(--input-width)+8px)] scroll-py-1 rounded-xl p-1 select-none empty:invisible',
|
||||||
|
// Invisible border that is only visible in `forced-colors` mode for accessibility purposes
|
||||||
|
'outline outline-transparent focus:outline-hidden',
|
||||||
|
// Handle scrolling when menu won't fit in viewport
|
||||||
|
'overflow-y-scroll overscroll-contain',
|
||||||
|
// Popover background
|
||||||
|
'bg-white/75 backdrop-blur-xl dark:bg-zinc-800/75',
|
||||||
|
// Shadows
|
||||||
|
'shadow-lg ring-1 ring-zinc-950/10 dark:ring-white/10 dark:ring-inset',
|
||||||
|
// Transitions
|
||||||
|
'transition-opacity duration-100 ease-in data-closed:data-leave:opacity-0 data-transition:pointer-events-none'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{({ option }) => children(option)}
|
||||||
|
</Headless.ComboboxOptions>
|
||||||
|
</Headless.Combobox>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComboboxOption<T>({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string; children?: React.ReactNode } & Omit<
|
||||||
|
Headless.ComboboxOptionProps<'div', T>,
|
||||||
|
'as' | 'className'
|
||||||
|
>) {
|
||||||
|
let sharedClasses = clsx(
|
||||||
|
// Base
|
||||||
|
'flex min-w-0 items-center',
|
||||||
|
// Icons
|
||||||
|
'*:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 sm:*:data-[slot=icon]:size-4',
|
||||||
|
'*:data-[slot=icon]:text-zinc-500 group-data-focus/option:*:data-[slot=icon]:text-white dark:*:data-[slot=icon]:text-zinc-400',
|
||||||
|
'forced-colors:*:data-[slot=icon]:text-[CanvasText] forced-colors:group-data-focus/option:*:data-[slot=icon]:text-[Canvas]',
|
||||||
|
// Avatars
|
||||||
|
'*:data-[slot=avatar]:-mx-0.5 *:data-[slot=avatar]:size-6 sm:*:data-[slot=avatar]:size-5'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Headless.ComboboxOption
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
// Basic layout
|
||||||
|
'group/option grid w-full cursor-default grid-cols-[1fr_--spacing(5)] items-baseline gap-x-2 rounded-lg py-2.5 pr-2 pl-3.5 sm:grid-cols-[1fr_--spacing(4)] sm:py-1.5 sm:pr-2 sm:pl-3',
|
||||||
|
// Typography
|
||||||
|
'text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white forced-colors:text-[CanvasText]',
|
||||||
|
// Focus
|
||||||
|
'outline-hidden data-focus:bg-blue-500 data-focus:text-white',
|
||||||
|
// Forced colors mode
|
||||||
|
'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]',
|
||||||
|
// Disabled
|
||||||
|
'data-disabled:opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={clsx(className, sharedClasses)}>{children}</span>
|
||||||
|
<svg
|
||||||
|
className="relative col-start-2 hidden size-5 self-center stroke-current group-data-selected/option:inline sm:size-4"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M4 8.5l3 3L12 4" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</Headless.ComboboxOption>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComboboxLabel({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return <span {...props} className={clsx(className, 'ml-2.5 truncate first:ml-0 sm:ml-2 sm:first:ml-0')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComboboxDescription({ className, children, ...props }: React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'flex flex-1 overflow-hidden text-zinc-500 group-data-focus/option:text-white before:w-2 before:min-w-0 before:shrink dark:text-zinc-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{children}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
src/app/components/description-list.tsx
Normal file
37
src/app/components/description-list.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
export function DescriptionList({ className, ...props }: React.ComponentPropsWithoutRef<'dl'>) {
|
||||||
|
return (
|
||||||
|
<dl
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'grid grid-cols-1 text-base/6 sm:grid-cols-[min(50%,--spacing(80))_auto] sm:text-sm/6'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DescriptionTerm({ className, ...props }: React.ComponentPropsWithoutRef<'dt'>) {
|
||||||
|
return (
|
||||||
|
<dt
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'col-start-1 border-t border-zinc-950/5 pt-3 text-zinc-500 first:border-none sm:border-t sm:border-zinc-950/5 sm:py-3 dark:border-white/5 dark:text-zinc-400 sm:dark:border-white/5'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DescriptionDetails({ className, ...props }: React.ComponentPropsWithoutRef<'dd'>) {
|
||||||
|
return (
|
||||||
|
<dd
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'pt-1 pb-3 text-zinc-950 sm:border-t sm:border-zinc-950/5 sm:py-3 sm:nth-2:border-none dark:text-white dark:sm:border-white/5'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
src/app/components/dialog.tsx
Normal file
86
src/app/components/dialog.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type React from 'react'
|
||||||
|
import { Text } from './text'
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
xs: 'sm:max-w-xs',
|
||||||
|
sm: 'sm:max-w-sm',
|
||||||
|
md: 'sm:max-w-md',
|
||||||
|
lg: 'sm:max-w-lg',
|
||||||
|
xl: 'sm:max-w-xl',
|
||||||
|
'2xl': 'sm:max-w-2xl',
|
||||||
|
'3xl': 'sm:max-w-3xl',
|
||||||
|
'4xl': 'sm:max-w-4xl',
|
||||||
|
'5xl': 'sm:max-w-5xl',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dialog({
|
||||||
|
size = 'lg',
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: { size?: keyof typeof sizes; className?: string; children: React.ReactNode } & Omit<
|
||||||
|
Headless.DialogProps,
|
||||||
|
'as' | 'className'
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<Headless.Dialog {...props}>
|
||||||
|
<Headless.DialogBackdrop
|
||||||
|
transition
|
||||||
|
className="fixed inset-0 flex w-screen justify-center overflow-y-auto bg-zinc-950/25 px-2 py-2 transition duration-100 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:px-6 sm:py-8 lg:px-8 lg:py-16 dark:bg-zinc-950/50"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 w-screen overflow-y-auto pt-6 sm:pt-0">
|
||||||
|
<div className="grid min-h-full grid-rows-[1fr_auto] justify-items-center sm:grid-rows-[1fr_auto_3fr] sm:p-4">
|
||||||
|
<Headless.DialogPanel
|
||||||
|
transition
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
sizes[size],
|
||||||
|
'row-start-2 w-full min-w-0 rounded-t-3xl bg-white p-(--gutter) shadow-lg ring-1 ring-zinc-950/10 [--gutter:--spacing(8)] sm:mb-auto sm:rounded-2xl dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline',
|
||||||
|
'transition duration-100 will-change-transform data-closed:translate-y-12 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:data-closed:translate-y-0 sm:data-closed:data-enter:scale-95'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Headless.DialogPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Headless.Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.DialogTitleProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.DialogTitle
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'text-lg/6 font-semibold text-balance text-zinc-950 sm:text-base/6 dark:text-white')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.DescriptionProps<typeof Text>, 'as' | 'className'>) {
|
||||||
|
return <Headless.Description as={Text} {...props} className={clsx(className, 'mt-2 text-pretty')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div {...props} className={clsx(className, 'mt-6')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogActions({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'mt-8 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:flex-row sm:*:w-auto'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
src/app/components/divider.tsx
Normal file
20
src/app/components/divider.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
export function Divider({
|
||||||
|
soft = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { soft?: boolean } & React.ComponentPropsWithoutRef<'hr'>) {
|
||||||
|
return (
|
||||||
|
<hr
|
||||||
|
role="presentation"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'w-full border-t',
|
||||||
|
soft && 'border-zinc-950/5 dark:border-white/5',
|
||||||
|
!soft && 'border-zinc-950/10 dark:border-white/10'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
183
src/app/components/dropdown.tsx
Normal file
183
src/app/components/dropdown.tsx
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type React from 'react'
|
||||||
|
import { Button } from './button'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
export function Dropdown(props: Headless.MenuProps) {
|
||||||
|
return <Headless.Menu {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownButton<T extends React.ElementType = typeof Button>({
|
||||||
|
as = Button,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.MenuButtonProps<T>, 'className'>) {
|
||||||
|
return <Headless.MenuButton as={as} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownMenu({
|
||||||
|
anchor = 'bottom',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.MenuItemsProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.MenuItems
|
||||||
|
{...props}
|
||||||
|
transition
|
||||||
|
anchor={anchor}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Anchor positioning
|
||||||
|
'[--anchor-gap:--spacing(2)] [--anchor-padding:--spacing(1)] data-[anchor~=end]:[--anchor-offset:6px] data-[anchor~=start]:[--anchor-offset:-6px] sm:data-[anchor~=end]:[--anchor-offset:4px] sm:data-[anchor~=start]:[--anchor-offset:-4px]',
|
||||||
|
// Base styles
|
||||||
|
'isolate w-max rounded-xl p-1',
|
||||||
|
// Invisible border that is only visible in `forced-colors` mode for accessibility purposes
|
||||||
|
'outline outline-transparent focus:outline-hidden',
|
||||||
|
// Handle scrolling when menu won't fit in viewport
|
||||||
|
'overflow-y-auto',
|
||||||
|
// Popover background
|
||||||
|
'bg-white/75 backdrop-blur-xl dark:bg-zinc-800/75',
|
||||||
|
// Shadows
|
||||||
|
'shadow-lg ring-1 ring-zinc-950/10 dark:ring-white/10 dark:ring-inset',
|
||||||
|
// Define grid at the menu level if subgrid is supported
|
||||||
|
'supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[auto_1fr_1.5rem_0.5rem_auto]',
|
||||||
|
// Transitions
|
||||||
|
'transition data-leave:duration-100 data-leave:ease-in data-closed:data-leave:opacity-0'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & (
|
||||||
|
| ({ href?: never } & Omit<Headless.MenuItemProps<'button'>, 'as' | 'className'>)
|
||||||
|
| ({ href: string } & Omit<Headless.MenuItemProps<typeof Link>, 'as' | 'className'>)
|
||||||
|
)) {
|
||||||
|
let classes = clsx(
|
||||||
|
className,
|
||||||
|
// Base styles
|
||||||
|
'group cursor-default rounded-lg px-3.5 py-2.5 focus:outline-hidden sm:px-3 sm:py-1.5',
|
||||||
|
// Text styles
|
||||||
|
'text-left text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white forced-colors:text-[CanvasText]',
|
||||||
|
// Focus
|
||||||
|
'data-focus:bg-blue-500 data-focus:text-white',
|
||||||
|
// Disabled state
|
||||||
|
'data-disabled:opacity-50',
|
||||||
|
// Forced colors mode
|
||||||
|
'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText] forced-colors:data-focus:*:data-[slot=icon]:text-[HighlightText]',
|
||||||
|
// Use subgrid when available but fallback to an explicit grid layout if not
|
||||||
|
'col-span-full grid grid-cols-[auto_1fr_1.5rem_0.5rem_auto] items-center supports-[grid-template-columns:subgrid]:grid-cols-subgrid',
|
||||||
|
// Icons
|
||||||
|
'*:data-[slot=icon]:col-start-1 *:data-[slot=icon]:row-start-1 *:data-[slot=icon]:mr-2.5 *:data-[slot=icon]:-ml-0.5 *:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:mr-2 sm:*:data-[slot=icon]:size-4',
|
||||||
|
'*:data-[slot=icon]:text-zinc-500 data-focus:*:data-[slot=icon]:text-white dark:*:data-[slot=icon]:text-zinc-400 dark:data-focus:*:data-[slot=icon]:text-white',
|
||||||
|
// Avatar
|
||||||
|
'*:data-[slot=avatar]:mr-2.5 *:data-[slot=avatar]:-ml-1 *:data-[slot=avatar]:size-6 sm:*:data-[slot=avatar]:mr-2 sm:*:data-[slot=avatar]:size-5'
|
||||||
|
)
|
||||||
|
|
||||||
|
return typeof props.href === 'string' ? (
|
||||||
|
<Headless.MenuItem as={Link} {...props} className={classes} />
|
||||||
|
) : (
|
||||||
|
<Headless.MenuItem as="button" type="button" {...props} className={classes} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownHeader({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div {...props} className={clsx(className, 'col-span-5 px-3.5 pt-2.5 pb-1 sm:px-3')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownSection({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.MenuSectionProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.MenuSection
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Define grid at the section level instead of the item level if subgrid is supported
|
||||||
|
'col-span-full supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[auto_1fr_1.5rem_0.5rem_auto]'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownHeading({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.MenuHeadingProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.MenuHeading
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'col-span-full grid grid-cols-[1fr_auto] gap-x-12 px-3.5 pt-2 pb-1 text-sm/5 font-medium text-zinc-500 sm:px-3 sm:text-xs/5 dark:text-zinc-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownDivider({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.MenuSeparatorProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.MenuSeparator
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'col-span-full mx-3.5 my-1 h-px border-0 bg-zinc-950/5 sm:mx-3 dark:bg-white/10 forced-colors:bg-[CanvasText]'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownLabel({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div {...props} data-slot="label" className={clsx(className, 'col-start-2 row-start-1')} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.DescriptionProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Description
|
||||||
|
data-slot="description"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'col-span-2 col-start-2 row-start-2 text-sm/5 text-zinc-500 group-data-focus:text-white sm:text-xs/5 dark:text-zinc-400 forced-colors:group-data-focus:text-[HighlightText]'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownShortcut({
|
||||||
|
keys,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { keys: string | string[]; className?: string } & Omit<Headless.DescriptionProps<'kbd'>, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Description
|
||||||
|
as="kbd"
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'col-start-5 row-start-1 flex justify-self-end')}
|
||||||
|
>
|
||||||
|
{(Array.isArray(keys) ? keys : keys.split('')).map((char, index) => (
|
||||||
|
<kbd
|
||||||
|
key={index}
|
||||||
|
className={clsx([
|
||||||
|
'min-w-[2ch] text-center font-sans text-zinc-400 capitalize group-data-focus:text-white forced-colors:group-data-focus:text-[HighlightText]',
|
||||||
|
// Make sure key names that are longer than one character (like "Tab") have extra space
|
||||||
|
index > 0 && char.length > 1 && 'pl-1',
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</Headless.Description>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
src/app/components/fieldset.tsx
Normal file
91
src/app/components/fieldset.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type React from 'react'
|
||||||
|
|
||||||
|
export function Fieldset({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.FieldsetProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Fieldset
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, '*:data-[slot=text]:mt-1 [&>*+[data-slot=control]]:mt-6')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Legend({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.LegendProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Legend
|
||||||
|
data-slot="legend"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'text-base/6 font-semibold text-zinc-950 data-disabled:opacity-50 sm:text-sm/6 dark:text-white'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div data-slot="control" {...props} className={clsx(className, 'space-y-8')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Field({ className, ...props }: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Field
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'[&>[data-slot=label]+[data-slot=control]]:mt-3',
|
||||||
|
'[&>[data-slot=label]+[data-slot=description]]:mt-1',
|
||||||
|
'[&>[data-slot=description]+[data-slot=control]]:mt-3',
|
||||||
|
'[&>[data-slot=control]+[data-slot=description]]:mt-3',
|
||||||
|
'[&>[data-slot=control]+[data-slot=error]]:mt-3',
|
||||||
|
'*:data-[slot=label]:font-medium'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Label({ className, ...props }: { className?: string } & Omit<Headless.LabelProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Label
|
||||||
|
data-slot="label"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'text-base/6 text-zinc-950 select-none data-disabled:opacity-50 sm:text-sm/6 dark:text-white'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Description({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.DescriptionProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Description
|
||||||
|
data-slot="description"
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'text-base/6 text-zinc-500 data-disabled:opacity-50 sm:text-sm/6 dark:text-zinc-400')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorMessage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.DescriptionProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Description
|
||||||
|
data-slot="error"
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'text-base/6 text-red-600 data-disabled:opacity-50 sm:text-sm/6 dark:text-red-500')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
src/app/components/heading.tsx
Normal file
27
src/app/components/heading.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
type HeadingProps = { level?: 1 | 2 | 3 | 4 | 5 | 6 } & React.ComponentPropsWithoutRef<
|
||||||
|
'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||||
|
>
|
||||||
|
|
||||||
|
export function Heading({ className, level = 1, ...props }: HeadingProps) {
|
||||||
|
let Element: `h${typeof level}` = `h${level}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Element
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'text-2xl/8 font-semibold text-zinc-950 sm:text-xl/8 dark:text-white')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Subheading({ className, level = 2, ...props }: HeadingProps) {
|
||||||
|
let Element: `h${typeof level}` = `h${level}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Element
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'text-base/7 font-semibold text-zinc-950 sm:text-sm/6 dark:text-white')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
92
src/app/components/input.tsx
Normal file
92
src/app/components/input.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React, { forwardRef } from 'react'
|
||||||
|
|
||||||
|
export function InputGroup({ children }: React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="control"
|
||||||
|
className={clsx(
|
||||||
|
'relative isolate block',
|
||||||
|
'has-[[data-slot=icon]:first-child]:[&_input]:pl-10 has-[[data-slot=icon]:last-child]:[&_input]:pr-10 sm:has-[[data-slot=icon]:first-child]:[&_input]:pl-8 sm:has-[[data-slot=icon]:last-child]:[&_input]:pr-8',
|
||||||
|
'*:data-[slot=icon]:pointer-events-none *:data-[slot=icon]:absolute *:data-[slot=icon]:top-3 *:data-[slot=icon]:z-10 *:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:top-2.5 sm:*:data-[slot=icon]:size-4',
|
||||||
|
'[&>[data-slot=icon]:first-child]:left-3 sm:[&>[data-slot=icon]:first-child]:left-2.5 [&>[data-slot=icon]:last-child]:right-3 sm:[&>[data-slot=icon]:last-child]:right-2.5',
|
||||||
|
'*:data-[slot=icon]:text-zinc-500 dark:*:data-[slot=icon]:text-zinc-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateTypes = ['date', 'datetime-local', 'month', 'time', 'week']
|
||||||
|
type DateType = (typeof dateTypes)[number]
|
||||||
|
|
||||||
|
export const Input = forwardRef(function Input(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
className?: string
|
||||||
|
type?: 'email' | 'number' | 'password' | 'search' | 'tel' | 'text' | 'url' | DateType
|
||||||
|
} & Omit<Headless.InputProps, 'as' | 'className'>,
|
||||||
|
ref: React.ForwardedRef<HTMLInputElement>
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="control"
|
||||||
|
className={clsx([
|
||||||
|
className,
|
||||||
|
// Basic layout
|
||||||
|
'relative block w-full',
|
||||||
|
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||||
|
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Focus ring
|
||||||
|
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
|
||||||
|
// Disabled state
|
||||||
|
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
<Headless.Input
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
className={clsx([
|
||||||
|
// Date classes
|
||||||
|
props.type &&
|
||||||
|
dateTypes.includes(props.type) && [
|
||||||
|
'[&::-webkit-datetime-edit-fields-wrapper]:p-0',
|
||||||
|
'[&::-webkit-date-and-time-value]:min-h-[1.5em]',
|
||||||
|
'[&::-webkit-datetime-edit]:inline-flex',
|
||||||
|
'[&::-webkit-datetime-edit]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-year-field]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-month-field]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-day-field]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-hour-field]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-minute-field]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-second-field]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-millisecond-field]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-meridiem-field]:p-0',
|
||||||
|
],
|
||||||
|
// Basic layout
|
||||||
|
'relative block w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
|
||||||
|
// Typography
|
||||||
|
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white',
|
||||||
|
// Border
|
||||||
|
'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
|
||||||
|
// Background color
|
||||||
|
'bg-transparent dark:bg-white/5',
|
||||||
|
// Hide default focus styles
|
||||||
|
'focus:outline-hidden',
|
||||||
|
// Invalid state
|
||||||
|
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
|
||||||
|
// Disabled state
|
||||||
|
'data-disabled:border-zinc-950/20 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/2.5 dark:data-hover:data-disabled:border-white/15',
|
||||||
|
// System icons
|
||||||
|
'dark:scheme-dark',
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
14
src/app/components/link.tsx
Normal file
14
src/app/components/link.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import NextLink, { type LinkProps } from 'next/link'
|
||||||
|
import React, { forwardRef } from 'react'
|
||||||
|
|
||||||
|
export const Link = forwardRef(function Link(
|
||||||
|
props: LinkProps & React.ComponentPropsWithoutRef<'a'>,
|
||||||
|
ref: React.ForwardedRef<HTMLAnchorElement>
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Headless.DataInteractive>
|
||||||
|
<NextLink {...props} ref={ref} />
|
||||||
|
</Headless.DataInteractive>
|
||||||
|
)
|
||||||
|
})
|
||||||
177
src/app/components/listbox.tsx
Normal file
177
src/app/components/listbox.tsx
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { Fragment } from 'react'
|
||||||
|
|
||||||
|
export function Listbox<T>({
|
||||||
|
className,
|
||||||
|
placeholder,
|
||||||
|
autoFocus,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
children: options,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
className?: string
|
||||||
|
placeholder?: React.ReactNode
|
||||||
|
autoFocus?: boolean
|
||||||
|
'aria-label'?: string
|
||||||
|
children?: React.ReactNode
|
||||||
|
} & Omit<Headless.ListboxProps<typeof Fragment, T>, 'as' | 'multiple'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Listbox {...props} multiple={false}>
|
||||||
|
<Headless.ListboxButton
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
data-slot="control"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={clsx([
|
||||||
|
className,
|
||||||
|
// Basic layout
|
||||||
|
'group relative block w-full',
|
||||||
|
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||||
|
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Hide default focus styles
|
||||||
|
'focus:outline-hidden',
|
||||||
|
// Focus ring
|
||||||
|
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset data-focus:after:ring-2 data-focus:after:ring-blue-500',
|
||||||
|
// Disabled state
|
||||||
|
'data-disabled:opacity-50 data-disabled:before:bg-zinc-950/5 data-disabled:before:shadow-none',
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
<Headless.ListboxSelectedOption
|
||||||
|
as="span"
|
||||||
|
options={options}
|
||||||
|
placeholder={placeholder && <span className="block truncate text-zinc-500">{placeholder}</span>}
|
||||||
|
className={clsx([
|
||||||
|
// Basic layout
|
||||||
|
'relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
|
||||||
|
// Set minimum height for when no value is selected
|
||||||
|
'min-h-11 sm:min-h-9',
|
||||||
|
// Horizontal padding
|
||||||
|
'pr-[calc(--spacing(7)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pl-[calc(--spacing(3)-1px)]',
|
||||||
|
// Typography
|
||||||
|
'text-left text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white forced-colors:text-[CanvasText]',
|
||||||
|
// Border
|
||||||
|
'border border-zinc-950/10 group-data-active:border-zinc-950/20 group-data-hover:border-zinc-950/20 dark:border-white/10 dark:group-data-active:border-white/20 dark:group-data-hover:border-white/20',
|
||||||
|
// Background color
|
||||||
|
'bg-transparent dark:bg-white/5',
|
||||||
|
// Invalid state
|
||||||
|
'group-data-invalid:border-red-500 group-data-hover:group-data-invalid:border-red-500 dark:group-data-invalid:border-red-600 dark:data-hover:group-data-invalid:border-red-600',
|
||||||
|
// Disabled state
|
||||||
|
'group-data-disabled:border-zinc-950/20 group-data-disabled:opacity-100 dark:group-data-disabled:border-white/15 dark:group-data-disabled:bg-white/2.5 dark:group-data-disabled:data-hover:border-white/15',
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
|
<svg
|
||||||
|
className="size-5 stroke-zinc-500 group-data-disabled:stroke-zinc-600 sm:size-4 dark:stroke-zinc-400 forced-colors:stroke-[CanvasText]"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path d="M5.75 10.75L8 13L10.25 10.75" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M10.25 5.25L8 3L5.75 5.25" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</Headless.ListboxButton>
|
||||||
|
<Headless.ListboxOptions
|
||||||
|
transition
|
||||||
|
anchor="selection start"
|
||||||
|
className={clsx(
|
||||||
|
// Anchor positioning
|
||||||
|
'[--anchor-offset:-1.625rem] [--anchor-padding:--spacing(4)] sm:[--anchor-offset:-1.375rem]',
|
||||||
|
// Base styles
|
||||||
|
'isolate w-max min-w-[calc(var(--button-width)+1.75rem)] scroll-py-1 rounded-xl p-1 select-none',
|
||||||
|
// Invisible border that is only visible in `forced-colors` mode for accessibility purposes
|
||||||
|
'outline outline-transparent focus:outline-hidden',
|
||||||
|
// Handle scrolling when menu won't fit in viewport
|
||||||
|
'overflow-y-scroll overscroll-contain',
|
||||||
|
// Popover background
|
||||||
|
'bg-white/75 backdrop-blur-xl dark:bg-zinc-800/75',
|
||||||
|
// Shadows
|
||||||
|
'shadow-lg ring-1 ring-zinc-950/10 dark:ring-white/10 dark:ring-inset',
|
||||||
|
// Transitions
|
||||||
|
'transition-opacity duration-100 ease-in data-closed:data-leave:opacity-0 data-transition:pointer-events-none'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{options}
|
||||||
|
</Headless.ListboxOptions>
|
||||||
|
</Headless.Listbox>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListboxOption<T>({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string; children?: React.ReactNode } & Omit<
|
||||||
|
Headless.ListboxOptionProps<'div', T>,
|
||||||
|
'as' | 'className'
|
||||||
|
>) {
|
||||||
|
let sharedClasses = clsx(
|
||||||
|
// Base
|
||||||
|
'flex min-w-0 items-center',
|
||||||
|
// Icons
|
||||||
|
'*:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 sm:*:data-[slot=icon]:size-4',
|
||||||
|
'*:data-[slot=icon]:text-zinc-500 group-data-focus/option:*:data-[slot=icon]:text-white dark:*:data-[slot=icon]:text-zinc-400',
|
||||||
|
'forced-colors:*:data-[slot=icon]:text-[CanvasText] forced-colors:group-data-focus/option:*:data-[slot=icon]:text-[Canvas]',
|
||||||
|
// Avatars
|
||||||
|
'*:data-[slot=avatar]:-mx-0.5 *:data-[slot=avatar]:size-6 sm:*:data-[slot=avatar]:size-5'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Headless.ListboxOption as={Fragment} {...props}>
|
||||||
|
{({ selectedOption }) => {
|
||||||
|
if (selectedOption) {
|
||||||
|
return <div className={clsx(className, sharedClasses)}>{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
// Basic layout
|
||||||
|
'group/option grid cursor-default grid-cols-[--spacing(5)_1fr] items-baseline gap-x-2 rounded-lg py-2.5 pr-3.5 pl-2 sm:grid-cols-[--spacing(4)_1fr] sm:py-1.5 sm:pr-3 sm:pl-1.5',
|
||||||
|
// Typography
|
||||||
|
'text-base/6 text-zinc-950 sm:text-sm/6 dark:text-white forced-colors:text-[CanvasText]',
|
||||||
|
// Focus
|
||||||
|
'outline-hidden data-focus:bg-blue-500 data-focus:text-white',
|
||||||
|
// Forced colors mode
|
||||||
|
'forced-color-adjust-none forced-colors:data-focus:bg-[Highlight] forced-colors:data-focus:text-[HighlightText]',
|
||||||
|
// Disabled
|
||||||
|
'data-disabled:opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="relative hidden size-5 self-center stroke-current group-data-selected/option:inline sm:size-4"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M4 8.5l3 3L12 4" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span className={clsx(className, sharedClasses, 'col-start-2')}>{children}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Headless.ListboxOption>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListboxLabel({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return <span {...props} className={clsx(className, 'ml-2.5 truncate first:ml-0 sm:ml-2 sm:first:ml-0')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListboxDescription({ className, children, ...props }: React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'flex flex-1 overflow-hidden text-zinc-500 group-data-focus/option:text-white before:w-2 before:min-w-0 before:shrink dark:text-zinc-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{children}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
338
src/app/components/nav/Header.tsx
Normal file
338
src/app/components/nav/Header.tsx
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogPanel,
|
||||||
|
Disclosure,
|
||||||
|
DisclosureButton,
|
||||||
|
DisclosurePanel,
|
||||||
|
Popover,
|
||||||
|
PopoverButton,
|
||||||
|
PopoverGroup,
|
||||||
|
PopoverPanel,
|
||||||
|
} from '@headlessui/react'
|
||||||
|
import {
|
||||||
|
Bars3Icon,
|
||||||
|
ShoppingBagIcon,
|
||||||
|
UsersIcon,
|
||||||
|
HomeIcon,
|
||||||
|
UserCircleIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
ArrowRightOnRectangleIcon
|
||||||
|
} from '@heroicons/react/24/outline'
|
||||||
|
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||||
|
import useAuthStore from '../../store/authStore';
|
||||||
|
import { Avatar } from '../avatar';
|
||||||
|
import LanguageSwitcher from '../LanguageSwitcher';
|
||||||
|
|
||||||
|
const products = [
|
||||||
|
{
|
||||||
|
name: 'Shop',
|
||||||
|
description: 'Browse our exclusive product catalog',
|
||||||
|
href: '/shop',
|
||||||
|
icon: ShoppingBagIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Community',
|
||||||
|
description: 'Connect with other members',
|
||||||
|
href: '/community',
|
||||||
|
icon: UsersIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Dashboard',
|
||||||
|
description: 'Manage your account and activities',
|
||||||
|
href: '/dashboard',
|
||||||
|
icon: HomeIcon
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Header() {
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
router.push('/login');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Logout failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get user initials for profile icon
|
||||||
|
const getUserInitials = () => {
|
||||||
|
if (!user) return 'U';
|
||||||
|
if (user.firstName || user.lastName) {
|
||||||
|
return (
|
||||||
|
(user.firstName?.[0] || '') +
|
||||||
|
(user.lastName?.[0] || '')
|
||||||
|
).toUpperCase();
|
||||||
|
}
|
||||||
|
if (user.email) {
|
||||||
|
return user.email[0].toUpperCase();
|
||||||
|
}
|
||||||
|
return 'U';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="relative isolate z-10 bg-gray-900">
|
||||||
|
<nav aria-label="Global" className="mx-auto flex max-w-7xl items-center justify-between p-6 lg:px-8">
|
||||||
|
<div className="flex lg:flex-1">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/')}
|
||||||
|
className="-m-1.5 p-1.5"
|
||||||
|
>
|
||||||
|
<span className="sr-only">ProfitPlanet</span>
|
||||||
|
<Image
|
||||||
|
src="/images/logos/pp_logo_gold_transparent.png"
|
||||||
|
alt="ProfitPlanet Logo"
|
||||||
|
width={180}
|
||||||
|
height={48}
|
||||||
|
className="h-12 w-auto"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex lg:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMobileMenuOpen(true)}
|
||||||
|
className="-m-2.5 inline-flex items-center justify-center rounded-md p-2.5 text-gray-400"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Open main menu</span>
|
||||||
|
<Bars3Icon aria-hidden="true" className="size-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<PopoverGroup className="hidden lg:flex lg:gap-x-12">
|
||||||
|
<Popover>
|
||||||
|
<PopoverButton className="flex items-center gap-x-1 text-sm/6 font-semibold text-white">
|
||||||
|
Product
|
||||||
|
<ChevronDownIcon aria-hidden="true" className="size-5 flex-none text-gray-500" />
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverPanel
|
||||||
|
transition
|
||||||
|
className="absolute inset-x-0 top-16 bg-gray-900 transition data-closed:-translate-y-1 data-closed:opacity-0 data-enter:duration-200 data-enter:ease-out data-leave:duration-150 data-leave:ease-in"
|
||||||
|
>
|
||||||
|
<div aria-hidden="true" className="absolute inset-0 top-1/2 bg-gray-900 ring-1 ring-white/15" />
|
||||||
|
<div className="relative bg-gray-900">
|
||||||
|
<div className="mx-auto grid max-w-7xl grid-cols-3 gap-x-4 px-6 py-10 lg:px-8 xl:gap-x-8">
|
||||||
|
{products.map((item) => (
|
||||||
|
<div key={item.name} className="group relative rounded-lg p-6 text-sm/6 hover:bg-white/5">
|
||||||
|
<div className="flex size-11 items-center justify-center rounded-lg bg-gray-700/50 group-hover:bg-gray-700">
|
||||||
|
<item.icon aria-hidden="true" className="size-6 text-gray-400 group-hover:text-white" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(item.href)}
|
||||||
|
className="mt-6 block font-semibold text-white"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
<span className="absolute inset-0" />
|
||||||
|
</button>
|
||||||
|
<p className="mt-1 text-gray-400">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverPanel>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/features')}
|
||||||
|
className="text-sm/6 font-semibold text-white hover:text-indigo-400 transition-colors"
|
||||||
|
>
|
||||||
|
Features
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/marketplace')}
|
||||||
|
className="text-sm/6 font-semibold text-white hover:text-indigo-400 transition-colors"
|
||||||
|
>
|
||||||
|
Marketplace
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/company')}
|
||||||
|
className="text-sm/6 font-semibold text-white hover:text-indigo-400 transition-colors"
|
||||||
|
>
|
||||||
|
Company
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Profile Dropdown - nur wenn eingeloggt */}
|
||||||
|
{user && (
|
||||||
|
<Popover>
|
||||||
|
<PopoverButton className="flex items-center gap-x-1 text-sm/6 font-semibold text-white">
|
||||||
|
<Avatar
|
||||||
|
src=""
|
||||||
|
initials={getUserInitials()}
|
||||||
|
className="size-8 bg-gradient-to-br from-indigo-500/40 to-indigo-600/60 text-white"
|
||||||
|
/>
|
||||||
|
<ChevronDownIcon aria-hidden="true" className="size-5 flex-none text-gray-500" />
|
||||||
|
</PopoverButton>
|
||||||
|
|
||||||
|
<PopoverPanel
|
||||||
|
transition
|
||||||
|
className="absolute right-0 top-16 w-64 bg-gray-900 ring-1 ring-white/15 transition data-closed:-translate-y-1 data-closed:opacity-0 data-enter:duration-200 data-enter:ease-out data-leave:duration-150 data-leave:ease-in"
|
||||||
|
>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex flex-col border-b border-white/10 pb-4 mb-4">
|
||||||
|
<div className="font-medium text-white">
|
||||||
|
{user?.firstName && user?.lastName
|
||||||
|
? `${user.firstName} ${user.lastName}`
|
||||||
|
: user?.email || 'User'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
{user?.email || 'user@example.com'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/profile')}
|
||||||
|
className="flex items-center gap-x-2 w-full text-left p-2 text-sm text-white hover:bg-white/5 rounded-md"
|
||||||
|
>
|
||||||
|
<UserCircleIcon className="size-5 text-gray-400" />
|
||||||
|
Profile
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center gap-x-2 w-full text-left p-2 text-sm text-white hover:bg-white/5 rounded-md"
|
||||||
|
>
|
||||||
|
<ArrowRightOnRectangleIcon className="size-5 text-gray-400" />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</PopoverPanel>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</PopoverGroup>
|
||||||
|
<div className="hidden lg:flex lg:flex-1 lg:justify-end lg:items-center lg:gap-x-4">
|
||||||
|
{/* Language Switcher */}
|
||||||
|
<LanguageSwitcher variant="dark" />
|
||||||
|
|
||||||
|
{!user && (
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/login')}
|
||||||
|
className="text-sm/6 font-semibold text-white hover:text-indigo-400 transition-colors"
|
||||||
|
>
|
||||||
|
Log in <span aria-hidden="true">→</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<Dialog open={mobileMenuOpen} onClose={setMobileMenuOpen} className="lg:hidden">
|
||||||
|
<div className="fixed inset-0 z-50" />
|
||||||
|
<DialogPanel className="fixed inset-y-0 right-0 z-50 w-full overflow-y-auto bg-gray-900 p-6 sm:max-w-sm sm:ring-1 sm:ring-gray-100/10">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/')}
|
||||||
|
className="-m-1.5 p-1.5"
|
||||||
|
>
|
||||||
|
<span className="sr-only">ProfitPlanet</span>
|
||||||
|
<Image
|
||||||
|
src="/images/logos/pp_logo_gold_transparent.png"
|
||||||
|
alt="ProfitPlanet Logo"
|
||||||
|
width={150}
|
||||||
|
height={40}
|
||||||
|
className="h-10 w-auto"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className="-m-2.5 rounded-md p-2.5 text-gray-400"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Close menu</span>
|
||||||
|
<XMarkIcon aria-hidden="true" className="size-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flow-root">
|
||||||
|
<div className="-my-6 divide-y divide-white/10">
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2 py-6">
|
||||||
|
<Disclosure as="div" className="-mx-3">
|
||||||
|
<DisclosureButton className="group flex w-full items-center justify-between rounded-lg py-2 pr-3.5 pl-3 text-base/7 font-semibold text-white hover:bg-white/5">
|
||||||
|
Navigation
|
||||||
|
<ChevronDownIcon aria-hidden="true" className="size-5 flex-none group-data-open:rotate-180" />
|
||||||
|
</DisclosureButton>
|
||||||
|
<DisclosurePanel className="mt-2 space-y-2">
|
||||||
|
{products.map((item) => (
|
||||||
|
<DisclosureButton
|
||||||
|
key={item.name}
|
||||||
|
as="button"
|
||||||
|
onClick={() => {
|
||||||
|
router.push(item.href);
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className="block rounded-lg py-2 pr-3 pl-6 text-sm/7 font-semibold text-white hover:bg-white/5 w-full text-left"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</DisclosureButton>
|
||||||
|
))}
|
||||||
|
</DisclosurePanel>
|
||||||
|
</Disclosure>
|
||||||
|
</div>
|
||||||
|
<div className="py-6 space-y-2">
|
||||||
|
<div className="flex flex-col border-b border-white/10 pb-4 mb-4 px-3">
|
||||||
|
<div className="font-medium text-white">
|
||||||
|
{user?.firstName && user?.lastName
|
||||||
|
? `${user.firstName} ${user.lastName}`
|
||||||
|
: user?.email || 'User'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-400">
|
||||||
|
{user?.email || 'user@example.com'}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="text-sm font-medium text-gray-400 mb-2">Language</div>
|
||||||
|
<LanguageSwitcher variant="dark" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
router.push('/profile');
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className="-mx-3 block rounded-lg px-3 py-2 text-base/7 font-semibold text-white hover:bg-white/5 w-full text-left"
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleLogout();
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className="-mx-3 block rounded-lg px-3 py-2 text-base/7 font-semibold text-white hover:bg-white/5 w-full text-left"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="py-6 space-y-4">
|
||||||
|
<div className="-mx-3 px-3">
|
||||||
|
<div className="text-sm font-medium text-gray-400 mb-2">Language</div>
|
||||||
|
<LanguageSwitcher variant="dark" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
router.push('/login');
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className="-mx-3 block rounded-lg px-3 py-2.5 text-base/7 font-semibold text-white hover:bg-white/5 w-full text-left"
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</Dialog>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
96
src/app/components/navbar.tsx
Normal file
96
src/app/components/navbar.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { LayoutGroup, motion } from 'motion/react'
|
||||||
|
import React, { forwardRef, useId } from 'react'
|
||||||
|
import { TouchTarget } from './button'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
export function Navbar({ className, ...props }: React.ComponentPropsWithoutRef<'nav'>) {
|
||||||
|
return <nav {...props} className={clsx(className, 'flex flex-1 items-center gap-4 py-2.5')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavbarDivider({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div aria-hidden="true" {...props} className={clsx(className, 'h-6 w-px bg-zinc-950/10 dark:bg-white/10')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavbarSection({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
let id = useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LayoutGroup id={id}>
|
||||||
|
<div {...props} className={clsx(className, 'flex items-center gap-3')} />
|
||||||
|
</LayoutGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavbarSpacer({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div aria-hidden="true" {...props} className={clsx(className, '-ml-4 flex-1')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NavbarItem = forwardRef(function NavbarItem(
|
||||||
|
{
|
||||||
|
current,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: { current?: boolean; className?: string; children: React.ReactNode } & (
|
||||||
|
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
|
||||||
|
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
|
||||||
|
),
|
||||||
|
ref: React.ForwardedRef<HTMLAnchorElement | HTMLButtonElement>
|
||||||
|
) {
|
||||||
|
let classes = clsx(
|
||||||
|
// Base
|
||||||
|
'relative flex min-w-0 items-center gap-3 rounded-lg p-2 text-left text-base/6 font-medium text-zinc-950 sm:text-sm/5',
|
||||||
|
// Leading icon/icon-only
|
||||||
|
'*:data-[slot=icon]:size-6 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:fill-zinc-500 sm:*:data-[slot=icon]:size-5',
|
||||||
|
// Trailing icon (down chevron or similar)
|
||||||
|
'*:not-nth-2:last:data-[slot=icon]:ml-auto *:not-nth-2:last:data-[slot=icon]:size-5 sm:*:not-nth-2:last:data-[slot=icon]:size-4',
|
||||||
|
// Avatar
|
||||||
|
'*:data-[slot=avatar]:-m-0.5 *:data-[slot=avatar]:size-7 *:data-[slot=avatar]:[--avatar-radius:var(--radius-md)] sm:*:data-[slot=avatar]:size-6',
|
||||||
|
// Hover
|
||||||
|
'data-hover:bg-zinc-950/5 data-hover:*:data-[slot=icon]:fill-zinc-950',
|
||||||
|
// Active
|
||||||
|
'data-active:bg-zinc-950/5 data-active:*:data-[slot=icon]:fill-zinc-950',
|
||||||
|
// Dark mode
|
||||||
|
'dark:text-white dark:*:data-[slot=icon]:fill-zinc-400',
|
||||||
|
'dark:data-hover:bg-white/5 dark:data-hover:*:data-[slot=icon]:fill-white',
|
||||||
|
'dark:data-active:bg-white/5 dark:data-active:*:data-[slot=icon]:fill-white'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={clsx(className, 'relative')}>
|
||||||
|
{current && (
|
||||||
|
<motion.span
|
||||||
|
layoutId="current-indicator"
|
||||||
|
className="absolute inset-x-2 -bottom-2.5 h-0.5 rounded-full bg-zinc-950 dark:bg-white"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{typeof props.href === 'string' ? (
|
||||||
|
<Link
|
||||||
|
{...props}
|
||||||
|
className={classes}
|
||||||
|
data-current={current ? 'true' : undefined}
|
||||||
|
ref={ref as React.ForwardedRef<HTMLAnchorElement>}
|
||||||
|
>
|
||||||
|
<TouchTarget>{children}</TouchTarget>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Headless.Button
|
||||||
|
{...props}
|
||||||
|
className={clsx('cursor-default', classes)}
|
||||||
|
data-current={current ? 'true' : undefined}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<TouchTarget>{children}</TouchTarget>
|
||||||
|
</Headless.Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function NavbarLabel({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return <span {...props} className={clsx(className, 'truncate')} />
|
||||||
|
}
|
||||||
98
src/app/components/pagination.tsx
Normal file
98
src/app/components/pagination.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import clsx from 'clsx'
|
||||||
|
import type React from 'react'
|
||||||
|
import { Button } from './button'
|
||||||
|
|
||||||
|
export function Pagination({
|
||||||
|
'aria-label': ariaLabel = 'Page navigation',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<'nav'>) {
|
||||||
|
return <nav aria-label={ariaLabel} {...props} className={clsx(className, 'flex gap-x-2')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaginationPrevious({
|
||||||
|
href = null,
|
||||||
|
className,
|
||||||
|
children = 'Previous',
|
||||||
|
}: React.PropsWithChildren<{ href?: string | null; className?: string }>) {
|
||||||
|
return (
|
||||||
|
<span className={clsx(className, 'grow basis-0')}>
|
||||||
|
<Button {...(href === null ? { disabled: true } : { href })} plain aria-label="Previous page">
|
||||||
|
<svg className="stroke-current" data-slot="icon" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M2.75 8H13.25M2.75 8L5.25 5.5M2.75 8L5.25 10.5"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaginationNext({
|
||||||
|
href = null,
|
||||||
|
className,
|
||||||
|
children = 'Next',
|
||||||
|
}: React.PropsWithChildren<{ href?: string | null; className?: string }>) {
|
||||||
|
return (
|
||||||
|
<span className={clsx(className, 'flex grow basis-0 justify-end')}>
|
||||||
|
<Button {...(href === null ? { disabled: true } : { href })} plain aria-label="Next page">
|
||||||
|
{children}
|
||||||
|
<svg className="stroke-current" data-slot="icon" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M13.25 8L2.75 8M13.25 8L10.75 10.5M13.25 8L10.75 5.5"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaginationList({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return <span {...props} className={clsx(className, 'hidden items-baseline gap-x-2 sm:flex')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaginationPage({
|
||||||
|
href,
|
||||||
|
className,
|
||||||
|
current = false,
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<{ href: string; className?: string; current?: boolean }>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
href={href}
|
||||||
|
plain
|
||||||
|
aria-label={`Page ${children}`}
|
||||||
|
aria-current={current ? 'page' : undefined}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'min-w-9 before:absolute before:-inset-px before:rounded-lg',
|
||||||
|
current && 'before:bg-zinc-950/5 dark:before:bg-white/10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="-mx-0.5">{children}</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaginationGap({
|
||||||
|
className,
|
||||||
|
children = <>…</>,
|
||||||
|
...props
|
||||||
|
}: React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'w-9 text-center text-sm/6 font-semibold text-zinc-950 select-none dark:text-white')}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
src/app/components/radio.tsx
Normal file
142
src/app/components/radio.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
export function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.RadioGroupProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.RadioGroup
|
||||||
|
data-slot="control"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Basic groups
|
||||||
|
'space-y-3 **:data-[slot=label]:font-normal',
|
||||||
|
// With descriptions
|
||||||
|
'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RadioField({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Field
|
||||||
|
data-slot="field"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Base layout
|
||||||
|
'grid grid-cols-[1.125rem_1fr] gap-x-4 gap-y-1 sm:grid-cols-[1rem_1fr]',
|
||||||
|
// Control layout
|
||||||
|
'*:data-[slot=control]:col-start-1 *:data-[slot=control]:row-start-1 *:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1',
|
||||||
|
// Label layout
|
||||||
|
'*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1',
|
||||||
|
// Description layout
|
||||||
|
'*:data-[slot=description]:col-start-2 *:data-[slot=description]:row-start-2',
|
||||||
|
// With description
|
||||||
|
'has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = [
|
||||||
|
// Basic layout
|
||||||
|
'relative isolate flex size-4.75 shrink-0 rounded-full sm:size-4.25',
|
||||||
|
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||||
|
'before:absolute before:inset-0 before:-z-10 before:rounded-full before:bg-white before:shadow-sm',
|
||||||
|
// Background color when checked
|
||||||
|
'group-data-checked:before:bg-(--radio-checked-bg)',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Background color applied to control in dark mode
|
||||||
|
'dark:bg-white/5 dark:group-data-checked:bg-(--radio-checked-bg)',
|
||||||
|
// Border
|
||||||
|
'border border-zinc-950/15 group-data-checked:border-transparent group-data-hover:group-data-checked:border-transparent group-data-hover:border-zinc-950/30 group-data-checked:bg-(--radio-checked-border)',
|
||||||
|
'dark:border-white/15 dark:group-data-checked:border-white/5 dark:group-data-hover:group-data-checked:border-white/5 dark:group-data-hover:border-white/30',
|
||||||
|
// Inner highlight shadow
|
||||||
|
'after:absolute after:inset-0 after:rounded-full after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
|
||||||
|
'dark:after:-inset-px dark:after:hidden dark:after:rounded-full dark:group-data-checked:after:block',
|
||||||
|
// Indicator color (light mode)
|
||||||
|
'[--radio-indicator:transparent] group-data-checked:[--radio-indicator:var(--radio-checked-indicator)] group-data-hover:group-data-checked:[--radio-indicator:var(--radio-checked-indicator)] group-data-hover:[--radio-indicator:var(--color-zinc-900)]/10',
|
||||||
|
// Indicator color (dark mode)
|
||||||
|
'dark:group-data-hover:group-data-checked:[--radio-indicator:var(--radio-checked-indicator)] dark:group-data-hover:[--radio-indicator:var(--color-zinc-700)]',
|
||||||
|
// Focus ring
|
||||||
|
'group-data-focus:outline group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-blue-500',
|
||||||
|
// Disabled state
|
||||||
|
'group-data-disabled:opacity-50',
|
||||||
|
'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--radio-checked-indicator:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent',
|
||||||
|
'dark:group-data-disabled:border-white/20 dark:group-data-disabled:bg-white/2.5 dark:group-data-disabled:[--radio-checked-indicator:var(--color-white)]/50 dark:group-data-checked:group-data-disabled:after:hidden',
|
||||||
|
]
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
'dark/zinc': [
|
||||||
|
'[--radio-checked-bg:var(--color-zinc-900)] [--radio-checked-border:var(--color-zinc-950)]/90 [--radio-checked-indicator:var(--color-white)]',
|
||||||
|
'dark:[--radio-checked-bg:var(--color-zinc-600)]',
|
||||||
|
],
|
||||||
|
'dark/white': [
|
||||||
|
'[--radio-checked-bg:var(--color-zinc-900)] [--radio-checked-border:var(--color-zinc-950)]/90 [--radio-checked-indicator:var(--color-white)]',
|
||||||
|
'dark:[--radio-checked-bg:var(--color-white)] dark:[--radio-checked-border:var(--color-zinc-950)]/15 dark:[--radio-checked-indicator:var(--color-zinc-900)]',
|
||||||
|
],
|
||||||
|
white:
|
||||||
|
'[--radio-checked-bg:var(--color-white)] [--radio-checked-border:var(--color-zinc-950)]/15 [--radio-checked-indicator:var(--color-zinc-900)]',
|
||||||
|
dark: '[--radio-checked-bg:var(--color-zinc-900)] [--radio-checked-border:var(--color-zinc-950)]/90 [--radio-checked-indicator:var(--color-white)]',
|
||||||
|
zinc: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-zinc-600)] [--radio-checked-border:var(--color-zinc-700)]/90',
|
||||||
|
red: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-red-600)] [--radio-checked-border:var(--color-red-700)]/90',
|
||||||
|
orange:
|
||||||
|
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-orange-500)] [--radio-checked-border:var(--color-orange-600)]/90',
|
||||||
|
amber:
|
||||||
|
'[--radio-checked-bg:var(--color-amber-400)] [--radio-checked-border:var(--color-amber-500)]/80 [--radio-checked-indicator:var(--color-amber-950)]',
|
||||||
|
yellow:
|
||||||
|
'[--radio-checked-bg:var(--color-yellow-300)] [--radio-checked-border:var(--color-yellow-400)]/80 [--radio-checked-indicator:var(--color-yellow-950)]',
|
||||||
|
lime: '[--radio-checked-bg:var(--color-lime-300)] [--radio-checked-border:var(--color-lime-400)]/80 [--radio-checked-indicator:var(--color-lime-950)]',
|
||||||
|
green:
|
||||||
|
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-green-600)] [--radio-checked-border:var(--color-green-700)]/90',
|
||||||
|
emerald:
|
||||||
|
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-emerald-600)] [--radio-checked-border:var(--color-emerald-700)]/90',
|
||||||
|
teal: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-teal-600)] [--radio-checked-border:var(--color-teal-700)]/90',
|
||||||
|
cyan: '[--radio-checked-bg:var(--color-cyan-300)] [--radio-checked-border:var(--color-cyan-400)]/80 [--radio-checked-indicator:var(--color-cyan-950)]',
|
||||||
|
sky: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-sky-500)] [--radio-checked-border:var(--color-sky-600)]/80',
|
||||||
|
blue: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-blue-600)] [--radio-checked-border:var(--color-blue-700)]/90',
|
||||||
|
indigo:
|
||||||
|
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-indigo-500)] [--radio-checked-border:var(--color-indigo-600)]/90',
|
||||||
|
violet:
|
||||||
|
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-violet-500)] [--radio-checked-border:var(--color-violet-600)]/90',
|
||||||
|
purple:
|
||||||
|
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-purple-500)] [--radio-checked-border:var(--color-purple-600)]/90',
|
||||||
|
fuchsia:
|
||||||
|
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-fuchsia-500)] [--radio-checked-border:var(--color-fuchsia-600)]/90',
|
||||||
|
pink: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-pink-500)] [--radio-checked-border:var(--color-pink-600)]/90',
|
||||||
|
rose: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-rose-500)] [--radio-checked-border:var(--color-rose-600)]/90',
|
||||||
|
}
|
||||||
|
|
||||||
|
type Color = keyof typeof colors
|
||||||
|
|
||||||
|
export function Radio({
|
||||||
|
color = 'dark/zinc',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { color?: Color; className?: string } & Omit<Headless.RadioProps, 'as' | 'className' | 'children'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Radio
|
||||||
|
data-slot="control"
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'group inline-flex focus:outline-hidden')}
|
||||||
|
>
|
||||||
|
<span className={clsx([base, colors[color]])}>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'size-full rounded-full border-[4.5px] border-transparent bg-(--radio-indicator) bg-clip-padding',
|
||||||
|
// Forced colors mode
|
||||||
|
'forced-colors:border-[Canvas] forced-colors:group-data-checked:border-[Highlight]'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Headless.Radio>
|
||||||
|
)
|
||||||
|
}
|
||||||
68
src/app/components/select.tsx
Normal file
68
src/app/components/select.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React, { forwardRef } from 'react'
|
||||||
|
|
||||||
|
export const Select = forwardRef(function Select(
|
||||||
|
{ className, multiple, ...props }: { className?: string } & Omit<Headless.SelectProps, 'as' | 'className'>,
|
||||||
|
ref: React.ForwardedRef<HTMLSelectElement>
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="control"
|
||||||
|
className={clsx([
|
||||||
|
className,
|
||||||
|
// Basic layout
|
||||||
|
'group relative block w-full',
|
||||||
|
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||||
|
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Focus ring
|
||||||
|
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset has-data-focus:after:ring-2 has-data-focus:after:ring-blue-500',
|
||||||
|
// Disabled state
|
||||||
|
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
<Headless.Select
|
||||||
|
ref={ref}
|
||||||
|
multiple={multiple}
|
||||||
|
{...props}
|
||||||
|
className={clsx([
|
||||||
|
// Basic layout
|
||||||
|
'relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
|
||||||
|
// Horizontal padding
|
||||||
|
multiple
|
||||||
|
? 'px-[calc(--spacing(3.5)-1px)] sm:px-[calc(--spacing(3)-1px)]'
|
||||||
|
: 'pr-[calc(--spacing(10)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pr-[calc(--spacing(9)-1px)] sm:pl-[calc(--spacing(3)-1px)]',
|
||||||
|
// Options (multi-select)
|
||||||
|
'[&_optgroup]:font-semibold',
|
||||||
|
// Typography
|
||||||
|
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white dark:*:text-white',
|
||||||
|
// Border
|
||||||
|
'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
|
||||||
|
// Background color
|
||||||
|
'bg-transparent dark:bg-white/5 dark:*:bg-zinc-800',
|
||||||
|
// Hide default focus styles
|
||||||
|
'focus:outline-hidden',
|
||||||
|
// Invalid state
|
||||||
|
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
|
||||||
|
// Disabled state
|
||||||
|
'data-disabled:border-zinc-950/20 data-disabled:opacity-100 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/2.5 dark:data-hover:data-disabled:border-white/15',
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
{!multiple && (
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
|
<svg
|
||||||
|
className="size-5 stroke-zinc-500 group-has-data-disabled:stroke-zinc-600 sm:size-4 dark:stroke-zinc-400 forced-colors:stroke-[CanvasText]"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path d="M5.75 10.75L8 13L10.25 10.75" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M10.25 5.25L8 3L5.75 5.25" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
82
src/app/components/sidebar-layout.tsx
Normal file
82
src/app/components/sidebar-layout.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { NavbarItem } from './navbar'
|
||||||
|
|
||||||
|
function OpenMenuIcon() {
|
||||||
|
return (
|
||||||
|
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path d="M2 6.75C2 6.33579 2.33579 6 2.75 6H17.25C17.6642 6 18 6.33579 18 6.75C18 7.16421 17.6642 7.5 17.25 7.5H2.75C2.33579 7.5 2 7.16421 2 6.75ZM2 13.25C2 12.8358 2.33579 12.5 2.75 12.5H17.25C17.6642 12.5 18 12.8358 18 13.25C18 13.6642 17.6642 14 17.25 14H2.75C2.33579 14 2 13.6642 2 13.25Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloseMenuIcon() {
|
||||||
|
return (
|
||||||
|
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileSidebar({ open, close, children }: React.PropsWithChildren<{ open: boolean; close: () => void }>) {
|
||||||
|
return (
|
||||||
|
<Headless.Dialog open={open} onClose={close} className="lg:hidden">
|
||||||
|
<Headless.DialogBackdrop
|
||||||
|
transition
|
||||||
|
className="fixed inset-0 bg-black/30 transition data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in"
|
||||||
|
/>
|
||||||
|
<Headless.DialogPanel
|
||||||
|
transition
|
||||||
|
className="fixed inset-y-0 w-full max-w-80 p-2 transition duration-300 ease-in-out data-closed:-translate-x-full"
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col rounded-lg bg-white shadow-xs ring-1 ring-zinc-950/5 dark:bg-zinc-900 dark:ring-white/10">
|
||||||
|
<div className="-mb-3 px-4 pt-3">
|
||||||
|
<Headless.CloseButton as={NavbarItem} aria-label="Close navigation">
|
||||||
|
<CloseMenuIcon />
|
||||||
|
</Headless.CloseButton>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Headless.DialogPanel>
|
||||||
|
</Headless.Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarLayout({
|
||||||
|
navbar,
|
||||||
|
sidebar,
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<{ navbar: React.ReactNode; sidebar: React.ReactNode }>) {
|
||||||
|
let [showSidebar, setShowSidebar] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative isolate flex min-h-svh w-full bg-white max-lg:flex-col lg:bg-zinc-100 dark:bg-zinc-900 dark:lg:bg-zinc-950">
|
||||||
|
{/* Sidebar on desktop */}
|
||||||
|
<div className="fixed inset-y-0 left-0 w-64 max-lg:hidden">{sidebar}</div>
|
||||||
|
|
||||||
|
{/* Sidebar on mobile */}
|
||||||
|
<MobileSidebar open={showSidebar} close={() => setShowSidebar(false)}>
|
||||||
|
{sidebar}
|
||||||
|
</MobileSidebar>
|
||||||
|
|
||||||
|
{/* Navbar on mobile */}
|
||||||
|
<header className="flex items-center px-4 lg:hidden">
|
||||||
|
<div className="py-2.5">
|
||||||
|
<NavbarItem onClick={() => setShowSidebar(true)} aria-label="Open navigation">
|
||||||
|
<OpenMenuIcon />
|
||||||
|
</NavbarItem>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">{navbar}</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="flex flex-1 flex-col pb-2 lg:min-w-0 lg:pt-2 lg:pr-2 lg:pl-64">
|
||||||
|
<div className="grow p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-900 dark:lg:ring-white/10">
|
||||||
|
<div className="mx-auto max-w-6xl">{children}</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
src/app/components/sidebar.tsx
Normal file
142
src/app/components/sidebar.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { LayoutGroup, motion } from 'motion/react'
|
||||||
|
import React, { forwardRef, useId } from 'react'
|
||||||
|
import { TouchTarget } from './button'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
export function Sidebar({ className, ...props }: React.ComponentPropsWithoutRef<'nav'>) {
|
||||||
|
return <nav {...props} className={clsx(className, 'flex h-full min-h-0 flex-col')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarHeader({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'flex flex-col border-b border-zinc-950/5 p-4 dark:border-white/5 [&>[data-slot=section]+[data-slot=section]]:mt-2.5'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'flex flex-1 flex-col overflow-y-auto p-4 [&>[data-slot=section]+[data-slot=section]]:mt-8'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarFooter({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'flex flex-col border-t border-zinc-950/5 p-4 dark:border-white/5 [&>[data-slot=section]+[data-slot=section]]:mt-2.5'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarSection({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
let id = useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LayoutGroup id={id}>
|
||||||
|
<div {...props} data-slot="section" className={clsx(className, 'flex flex-col gap-0.5')} />
|
||||||
|
</LayoutGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarDivider({ className, ...props }: React.ComponentPropsWithoutRef<'hr'>) {
|
||||||
|
return <hr {...props} className={clsx(className, 'my-4 border-t border-zinc-950/5 lg:-mx-4 dark:border-white/5')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarSpacer({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div aria-hidden="true" {...props} className={clsx(className, 'mt-8 flex-1')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarHeading({ className, ...props }: React.ComponentPropsWithoutRef<'h3'>) {
|
||||||
|
return (
|
||||||
|
<h3 {...props} className={clsx(className, 'mb-1 px-2 text-xs/6 font-medium text-zinc-500 dark:text-zinc-400')} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarItem = forwardRef(function SidebarItem(
|
||||||
|
{
|
||||||
|
current,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: { current?: boolean; className?: string; children: React.ReactNode } & (
|
||||||
|
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
|
||||||
|
| ({ href: string } & Omit<Headless.ButtonProps<typeof Link>, 'as' | 'className'>)
|
||||||
|
),
|
||||||
|
ref: React.ForwardedRef<HTMLAnchorElement | HTMLButtonElement>
|
||||||
|
) {
|
||||||
|
let classes = clsx(
|
||||||
|
// Base
|
||||||
|
'flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left text-base/6 font-medium text-zinc-950 sm:py-2 sm:text-sm/5',
|
||||||
|
// Leading icon/icon-only
|
||||||
|
'*:data-[slot=icon]:size-6 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:fill-zinc-500 sm:*:data-[slot=icon]:size-5',
|
||||||
|
// Trailing icon (down chevron or similar)
|
||||||
|
'*:last:data-[slot=icon]:ml-auto *:last:data-[slot=icon]:size-5 sm:*:last:data-[slot=icon]:size-4',
|
||||||
|
// Avatar
|
||||||
|
'*:data-[slot=avatar]:-m-0.5 *:data-[slot=avatar]:size-7 sm:*:data-[slot=avatar]:size-6',
|
||||||
|
// Hover
|
||||||
|
'data-hover:bg-zinc-950/5 data-hover:*:data-[slot=icon]:fill-zinc-950',
|
||||||
|
// Active
|
||||||
|
'data-active:bg-zinc-950/5 data-active:*:data-[slot=icon]:fill-zinc-950',
|
||||||
|
// Current
|
||||||
|
'data-current:*:data-[slot=icon]:fill-zinc-950',
|
||||||
|
// Dark mode
|
||||||
|
'dark:text-white dark:*:data-[slot=icon]:fill-zinc-400',
|
||||||
|
'dark:data-hover:bg-white/5 dark:data-hover:*:data-[slot=icon]:fill-white',
|
||||||
|
'dark:data-active:bg-white/5 dark:data-active:*:data-[slot=icon]:fill-white',
|
||||||
|
'dark:data-current:*:data-[slot=icon]:fill-white'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={clsx(className, 'relative')}>
|
||||||
|
{current && (
|
||||||
|
<motion.span
|
||||||
|
layoutId="current-indicator"
|
||||||
|
className="absolute inset-y-2 -left-4 w-0.5 rounded-full bg-zinc-950 dark:bg-white"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{typeof props.href === 'string' ? (
|
||||||
|
<Headless.CloseButton
|
||||||
|
as={Link}
|
||||||
|
{...props}
|
||||||
|
className={classes}
|
||||||
|
data-current={current ? 'true' : undefined}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<TouchTarget>{children}</TouchTarget>
|
||||||
|
</Headless.CloseButton>
|
||||||
|
) : (
|
||||||
|
<Headless.Button
|
||||||
|
{...props}
|
||||||
|
className={clsx('cursor-default', classes)}
|
||||||
|
data-current={current ? 'true' : undefined}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<TouchTarget>{children}</TouchTarget>
|
||||||
|
</Headless.Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function SidebarLabel({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return <span {...props} className={clsx(className, 'truncate')} />
|
||||||
|
}
|
||||||
79
src/app/components/stacked-layout.tsx
Normal file
79
src/app/components/stacked-layout.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { NavbarItem } from './navbar'
|
||||||
|
|
||||||
|
function OpenMenuIcon() {
|
||||||
|
return (
|
||||||
|
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path d="M2 6.75C2 6.33579 2.33579 6 2.75 6H17.25C17.6642 6 18 6.33579 18 6.75C18 7.16421 17.6642 7.5 17.25 7.5H2.75C2.33579 7.5 2 7.16421 2 6.75ZM2 13.25C2 12.8358 2.33579 12.5 2.75 12.5H17.25C17.6642 12.5 18 12.8358 18 13.25C18 13.6642 17.6642 14 17.25 14H2.75C2.33579 14 2 13.6642 2 13.25Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloseMenuIcon() {
|
||||||
|
return (
|
||||||
|
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileSidebar({ open, close, children }: React.PropsWithChildren<{ open: boolean; close: () => void }>) {
|
||||||
|
return (
|
||||||
|
<Headless.Dialog open={open} onClose={close} className="lg:hidden">
|
||||||
|
<Headless.DialogBackdrop
|
||||||
|
transition
|
||||||
|
className="fixed inset-0 bg-black/30 transition data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in"
|
||||||
|
/>
|
||||||
|
<Headless.DialogPanel
|
||||||
|
transition
|
||||||
|
className="fixed inset-y-0 w-full max-w-80 p-2 transition duration-300 ease-in-out data-closed:-translate-x-full"
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col rounded-lg bg-white shadow-xs ring-1 ring-zinc-950/5 dark:bg-zinc-900 dark:ring-white/10">
|
||||||
|
<div className="-mb-3 px-4 pt-3">
|
||||||
|
<Headless.CloseButton as={NavbarItem} aria-label="Close navigation">
|
||||||
|
<CloseMenuIcon />
|
||||||
|
</Headless.CloseButton>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Headless.DialogPanel>
|
||||||
|
</Headless.Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StackedLayout({
|
||||||
|
navbar,
|
||||||
|
sidebar,
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<{ navbar: React.ReactNode; sidebar: React.ReactNode }>) {
|
||||||
|
let [showSidebar, setShowSidebar] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative isolate flex min-h-svh w-full flex-col bg-white lg:bg-zinc-100 dark:bg-zinc-900 dark:lg:bg-zinc-950">
|
||||||
|
{/* Sidebar on mobile */}
|
||||||
|
<MobileSidebar open={showSidebar} close={() => setShowSidebar(false)}>
|
||||||
|
{sidebar}
|
||||||
|
</MobileSidebar>
|
||||||
|
|
||||||
|
{/* Navbar */}
|
||||||
|
<header className="flex items-center px-4">
|
||||||
|
<div className="py-2.5 lg:hidden">
|
||||||
|
<NavbarItem onClick={() => setShowSidebar(true)} aria-label="Open navigation">
|
||||||
|
<OpenMenuIcon />
|
||||||
|
</NavbarItem>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">{navbar}</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="flex flex-1 flex-col pb-2 lg:px-2">
|
||||||
|
<div className="grow p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-900 dark:lg:ring-white/10">
|
||||||
|
<div className="mx-auto max-w-6xl">{children}</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
195
src/app/components/switch.tsx
Normal file
195
src/app/components/switch.tsx
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type React from 'react'
|
||||||
|
|
||||||
|
export function SwitchGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="control"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Basic groups
|
||||||
|
'space-y-3 **:data-[slot=label]:font-normal',
|
||||||
|
// With descriptions
|
||||||
|
'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SwitchField({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Field
|
||||||
|
data-slot="field"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Base layout
|
||||||
|
'grid grid-cols-[1fr_auto] gap-x-8 gap-y-1 sm:grid-cols-[1fr_auto]',
|
||||||
|
// Control layout
|
||||||
|
'*:data-[slot=control]:col-start-2 *:data-[slot=control]:self-start sm:*:data-[slot=control]:mt-0.5',
|
||||||
|
// Label layout
|
||||||
|
'*:data-[slot=label]:col-start-1 *:data-[slot=label]:row-start-1',
|
||||||
|
// Description layout
|
||||||
|
'*:data-[slot=description]:col-start-1 *:data-[slot=description]:row-start-2',
|
||||||
|
// With description
|
||||||
|
'has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
'dark/zinc': [
|
||||||
|
'[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)] dark:[--switch-bg-ring:transparent] dark:[--switch-bg:var(--color-white)]/25',
|
||||||
|
'[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white] dark:[--switch-ring:var(--color-zinc-700)]/90',
|
||||||
|
],
|
||||||
|
'dark/white': [
|
||||||
|
'[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)] dark:[--switch-bg-ring:transparent] dark:[--switch-bg:var(--color-white)]',
|
||||||
|
'[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white] dark:[--switch-ring:transparent] dark:[--switch:var(--color-zinc-900)]',
|
||||||
|
],
|
||||||
|
dark: [
|
||||||
|
'[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)] dark:[--switch-bg-ring:var(--color-white)]/15',
|
||||||
|
'[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white]',
|
||||||
|
],
|
||||||
|
zinc: [
|
||||||
|
'[--switch-bg-ring:var(--color-zinc-700)]/90 [--switch-bg:var(--color-zinc-600)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch-shadow:var(--color-black)]/10 [--switch:white] [--switch-ring:var(--color-zinc-700)]/90',
|
||||||
|
],
|
||||||
|
white: [
|
||||||
|
'[--switch-bg-ring:var(--color-black)]/15 [--switch-bg:white] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch-shadow:var(--color-black)]/10 [--switch-ring:transparent] [--switch:var(--color-zinc-950)]',
|
||||||
|
],
|
||||||
|
red: [
|
||||||
|
'[--switch-bg-ring:var(--color-red-700)]/90 [--switch-bg:var(--color-red-600)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-red-700)]/90 [--switch-shadow:var(--color-red-900)]/20',
|
||||||
|
],
|
||||||
|
orange: [
|
||||||
|
'[--switch-bg-ring:var(--color-orange-600)]/90 [--switch-bg:var(--color-orange-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-orange-600)]/90 [--switch-shadow:var(--color-orange-900)]/20',
|
||||||
|
],
|
||||||
|
amber: [
|
||||||
|
'[--switch-bg-ring:var(--color-amber-500)]/80 [--switch-bg:var(--color-amber-400)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-amber-950)]',
|
||||||
|
],
|
||||||
|
yellow: [
|
||||||
|
'[--switch-bg-ring:var(--color-yellow-400)]/80 [--switch-bg:var(--color-yellow-300)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-yellow-950)]',
|
||||||
|
],
|
||||||
|
lime: [
|
||||||
|
'[--switch-bg-ring:var(--color-lime-400)]/80 [--switch-bg:var(--color-lime-300)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-lime-950)]',
|
||||||
|
],
|
||||||
|
green: [
|
||||||
|
'[--switch-bg-ring:var(--color-green-700)]/90 [--switch-bg:var(--color-green-600)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-green-700)]/90 [--switch-shadow:var(--color-green-900)]/20',
|
||||||
|
],
|
||||||
|
emerald: [
|
||||||
|
'[--switch-bg-ring:var(--color-emerald-600)]/90 [--switch-bg:var(--color-emerald-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-emerald-600)]/90 [--switch-shadow:var(--color-emerald-900)]/20',
|
||||||
|
],
|
||||||
|
teal: [
|
||||||
|
'[--switch-bg-ring:var(--color-teal-700)]/90 [--switch-bg:var(--color-teal-600)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-teal-700)]/90 [--switch-shadow:var(--color-teal-900)]/20',
|
||||||
|
],
|
||||||
|
cyan: [
|
||||||
|
'[--switch-bg-ring:var(--color-cyan-400)]/80 [--switch-bg:var(--color-cyan-300)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-cyan-950)]',
|
||||||
|
],
|
||||||
|
sky: [
|
||||||
|
'[--switch-bg-ring:var(--color-sky-600)]/80 [--switch-bg:var(--color-sky-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-sky-600)]/80 [--switch-shadow:var(--color-sky-900)]/20',
|
||||||
|
],
|
||||||
|
blue: [
|
||||||
|
'[--switch-bg-ring:var(--color-blue-700)]/90 [--switch-bg:var(--color-blue-600)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-blue-700)]/90 [--switch-shadow:var(--color-blue-900)]/20',
|
||||||
|
],
|
||||||
|
indigo: [
|
||||||
|
'[--switch-bg-ring:var(--color-indigo-600)]/90 [--switch-bg:var(--color-indigo-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-indigo-600)]/90 [--switch-shadow:var(--color-indigo-900)]/20',
|
||||||
|
],
|
||||||
|
violet: [
|
||||||
|
'[--switch-bg-ring:var(--color-violet-600)]/90 [--switch-bg:var(--color-violet-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-violet-600)]/90 [--switch-shadow:var(--color-violet-900)]/20',
|
||||||
|
],
|
||||||
|
purple: [
|
||||||
|
'[--switch-bg-ring:var(--color-purple-600)]/90 [--switch-bg:var(--color-purple-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-purple-600)]/90 [--switch-shadow:var(--color-purple-900)]/20',
|
||||||
|
],
|
||||||
|
fuchsia: [
|
||||||
|
'[--switch-bg-ring:var(--color-fuchsia-600)]/90 [--switch-bg:var(--color-fuchsia-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-fuchsia-600)]/90 [--switch-shadow:var(--color-fuchsia-900)]/20',
|
||||||
|
],
|
||||||
|
pink: [
|
||||||
|
'[--switch-bg-ring:var(--color-pink-600)]/90 [--switch-bg:var(--color-pink-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-pink-600)]/90 [--switch-shadow:var(--color-pink-900)]/20',
|
||||||
|
],
|
||||||
|
rose: [
|
||||||
|
'[--switch-bg-ring:var(--color-rose-600)]/90 [--switch-bg:var(--color-rose-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-rose-600)]/90 [--switch-shadow:var(--color-rose-900)]/20',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
type Color = keyof typeof colors
|
||||||
|
|
||||||
|
export function Switch({
|
||||||
|
color = 'dark/zinc',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
color?: Color
|
||||||
|
className?: string
|
||||||
|
} & Omit<Headless.SwitchProps, 'as' | 'className' | 'children'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Switch
|
||||||
|
data-slot="control"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Base styles
|
||||||
|
'group relative isolate inline-flex h-6 w-10 cursor-default rounded-full p-[3px] sm:h-5 sm:w-8',
|
||||||
|
// Transitions
|
||||||
|
'transition duration-0 ease-in-out data-changing:duration-200',
|
||||||
|
// Outline and background color in forced-colors mode so switch is still visible
|
||||||
|
'forced-colors:outline forced-colors:[--switch-bg:Highlight] dark:forced-colors:[--switch-bg:Highlight]',
|
||||||
|
// Unchecked
|
||||||
|
'bg-zinc-200 ring-1 ring-black/5 ring-inset dark:bg-white/5 dark:ring-white/15',
|
||||||
|
// Checked
|
||||||
|
'data-checked:bg-(--switch-bg) data-checked:ring-(--switch-bg-ring) dark:data-checked:bg-(--switch-bg) dark:data-checked:ring-(--switch-bg-ring)',
|
||||||
|
// Focus
|
||||||
|
'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500',
|
||||||
|
// Hover
|
||||||
|
'data-hover:ring-black/15 data-hover:data-checked:ring-(--switch-bg-ring)',
|
||||||
|
'dark:data-hover:ring-white/25 dark:data-hover:data-checked:ring-(--switch-bg-ring)',
|
||||||
|
// Disabled
|
||||||
|
'data-disabled:bg-zinc-200 data-disabled:opacity-50 data-disabled:data-checked:bg-zinc-200 data-disabled:data-checked:ring-black/5',
|
||||||
|
'dark:data-disabled:bg-white/15 dark:data-disabled:data-checked:bg-white/15 dark:data-disabled:data-checked:ring-white/15',
|
||||||
|
// Color specific styles
|
||||||
|
colors[color]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={clsx(
|
||||||
|
// Basic layout
|
||||||
|
'pointer-events-none relative inline-block size-4.5 rounded-full sm:size-3.5',
|
||||||
|
// Transition
|
||||||
|
'translate-x-0 transition duration-200 ease-in-out',
|
||||||
|
// Invisible border so the switch is still visible in forced-colors mode
|
||||||
|
'border border-transparent',
|
||||||
|
// Unchecked
|
||||||
|
'bg-white shadow-sm ring-1 ring-black/5',
|
||||||
|
// Checked
|
||||||
|
'group-data-checked:bg-(--switch) group-data-checked:shadow-(--switch-shadow) group-data-checked:ring-(--switch-ring)',
|
||||||
|
'group-data-checked:translate-x-4 sm:group-data-checked:translate-x-3',
|
||||||
|
// Disabled
|
||||||
|
'group-data-checked:group-data-disabled:bg-white group-data-checked:group-data-disabled:shadow-sm group-data-checked:group-data-disabled:ring-black/5'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Headless.Switch>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
src/app/components/table.tsx
Normal file
124
src/app/components/table.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type React from 'react'
|
||||||
|
import { createContext, useContext, useState } from 'react'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
const TableContext = createContext<{ bleed: boolean; dense: boolean; grid: boolean; striped: boolean }>({
|
||||||
|
bleed: false,
|
||||||
|
dense: false,
|
||||||
|
grid: false,
|
||||||
|
striped: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function Table({
|
||||||
|
bleed = false,
|
||||||
|
dense = false,
|
||||||
|
grid = false,
|
||||||
|
striped = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: { bleed?: boolean; dense?: boolean; grid?: boolean; striped?: boolean } & React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<TableContext.Provider value={{ bleed, dense, grid, striped } as React.ContextType<typeof TableContext>}>
|
||||||
|
<div className="flow-root">
|
||||||
|
<div {...props} className={clsx(className, '-mx-(--gutter) overflow-x-auto whitespace-nowrap')}>
|
||||||
|
<div className={clsx('inline-block min-w-full align-middle', !bleed && 'sm:px-(--gutter)')}>
|
||||||
|
<table className="min-w-full text-left text-sm/6 text-zinc-950 dark:text-white">{children}</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableHead({ className, ...props }: React.ComponentPropsWithoutRef<'thead'>) {
|
||||||
|
return <thead {...props} className={clsx(className, 'text-zinc-500 dark:text-zinc-400')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableBody(props: React.ComponentPropsWithoutRef<'tbody'>) {
|
||||||
|
return <tbody {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const TableRowContext = createContext<{ href?: string; target?: string; title?: string }>({
|
||||||
|
href: undefined,
|
||||||
|
target: undefined,
|
||||||
|
title: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function TableRow({
|
||||||
|
href,
|
||||||
|
target,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { href?: string; target?: string; title?: string } & React.ComponentPropsWithoutRef<'tr'>) {
|
||||||
|
let { striped } = useContext(TableContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRowContext.Provider value={{ href, target, title } as React.ContextType<typeof TableRowContext>}>
|
||||||
|
<tr
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
href &&
|
||||||
|
'has-[[data-row-link][data-focus]]:outline-2 has-[[data-row-link][data-focus]]:-outline-offset-2 has-[[data-row-link][data-focus]]:outline-blue-500 dark:focus-within:bg-white/2.5',
|
||||||
|
striped && 'even:bg-zinc-950/2.5 dark:even:bg-white/2.5',
|
||||||
|
href && striped && 'hover:bg-zinc-950/5 dark:hover:bg-white/5',
|
||||||
|
href && !striped && 'hover:bg-zinc-950/2.5 dark:hover:bg-white/2.5'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TableRowContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableHeader({ className, ...props }: React.ComponentPropsWithoutRef<'th'>) {
|
||||||
|
let { bleed, grid } = useContext(TableContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'border-b border-b-zinc-950/10 px-4 py-2 font-medium first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2)) dark:border-b-white/10',
|
||||||
|
grid && 'border-l border-l-zinc-950/5 first:border-l-0 dark:border-l-white/5',
|
||||||
|
!bleed && 'sm:first:pl-1 sm:last:pr-1'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableCell({ className, children, ...props }: React.ComponentPropsWithoutRef<'td'>) {
|
||||||
|
let { bleed, dense, grid, striped } = useContext(TableContext)
|
||||||
|
let { href, target, title } = useContext(TableRowContext)
|
||||||
|
let [cellRef, setCellRef] = useState<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
ref={href ? setCellRef : undefined}
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'relative px-4 first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2))',
|
||||||
|
!striped && 'border-b border-zinc-950/5 dark:border-white/5',
|
||||||
|
grid && 'border-l border-l-zinc-950/5 first:border-l-0 dark:border-l-white/5',
|
||||||
|
dense ? 'py-2.5' : 'py-4',
|
||||||
|
!bleed && 'sm:first:pl-1 sm:last:pr-1'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{href && (
|
||||||
|
<Link
|
||||||
|
data-row-link
|
||||||
|
href={href}
|
||||||
|
target={target}
|
||||||
|
aria-label={title}
|
||||||
|
tabIndex={cellRef?.previousElementSibling === null ? 0 : -1}
|
||||||
|
className="absolute inset-0 focus:outline-hidden"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
src/app/components/text.tsx
Normal file
40
src/app/components/text.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import clsx from 'clsx'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
export function Text({ className, ...props }: React.ComponentPropsWithoutRef<'p'>) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="text"
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextLink({ className, ...props }: React.ComponentPropsWithoutRef<typeof Link>) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'text-zinc-950 underline decoration-zinc-950/50 data-hover:decoration-zinc-950 dark:text-white dark:decoration-white/50 dark:data-hover:decoration-white'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Strong({ className, ...props }: React.ComponentPropsWithoutRef<'strong'>) {
|
||||||
|
return <strong {...props} className={clsx(className, 'font-medium text-zinc-950 dark:text-white')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Code({ className, ...props }: React.ComponentPropsWithoutRef<'code'>) {
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'rounded-sm border border-zinc-950/10 bg-zinc-950/2.5 px-0.5 text-sm font-medium text-zinc-950 sm:text-[0.8125rem] dark:border-white/20 dark:bg-white/5 dark:text-white'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
src/app/components/textarea.tsx
Normal file
54
src/app/components/textarea.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React, { forwardRef } from 'react'
|
||||||
|
|
||||||
|
export const Textarea = forwardRef(function Textarea(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
resizable = true,
|
||||||
|
...props
|
||||||
|
}: { className?: string; resizable?: boolean } & Omit<Headless.TextareaProps, 'as' | 'className'>,
|
||||||
|
ref: React.ForwardedRef<HTMLTextAreaElement>
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="control"
|
||||||
|
className={clsx([
|
||||||
|
className,
|
||||||
|
// Basic layout
|
||||||
|
'relative block w-full',
|
||||||
|
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||||
|
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Focus ring
|
||||||
|
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
|
||||||
|
// Disabled state
|
||||||
|
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
<Headless.Textarea
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
className={clsx([
|
||||||
|
// Basic layout
|
||||||
|
'relative block h-full w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
|
||||||
|
// Typography
|
||||||
|
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white',
|
||||||
|
// Border
|
||||||
|
'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
|
||||||
|
// Background color
|
||||||
|
'bg-transparent dark:bg-white/5',
|
||||||
|
// Hide default focus styles
|
||||||
|
'focus:outline-hidden',
|
||||||
|
// Invalid state
|
||||||
|
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
|
||||||
|
// Disabled state
|
||||||
|
'disabled:border-zinc-950/20 dark:disabled:border-white/15 dark:disabled:bg-white/2.5 dark:data-hover:disabled:border-white/15',
|
||||||
|
// Resizable
|
||||||
|
resizable ? 'resize-y' : 'resize-none',
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
37
src/app/components/ui/badge.tsx
Normal file
37
src/app/components/ui/badge.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
// Simple className utility
|
||||||
|
function cn(...classes: (string | undefined | null | boolean)[]): string {
|
||||||
|
return classes.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Badge variants
|
||||||
|
const badgeVariants = {
|
||||||
|
variant: {
|
||||||
|
default: "bg-[#8D6B1D] text-white",
|
||||||
|
secondary: "bg-gray-100 text-gray-900",
|
||||||
|
destructive: "bg-red-500 text-white",
|
||||||
|
outline: "border border-gray-300 text-gray-900",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
variant?: keyof typeof badgeVariants.variant
|
||||||
|
}
|
||||||
|
|
||||||
|
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||||
|
({ className, variant = "default", ...props }, ref) => {
|
||||||
|
const baseClasses = "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:ring-offset-2"
|
||||||
|
const variantClasses = badgeVariants.variant[variant]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(baseClasses, variantClasses, className)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Badge.displayName = "Badge"
|
||||||
|
|
||||||
|
export { Badge }
|
||||||
47
src/app/components/ui/button.tsx
Normal file
47
src/app/components/ui/button.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
// Simple className utility
|
||||||
|
function cn(...classes: (string | undefined | null | boolean)[]): string {
|
||||||
|
return classes.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button variants
|
||||||
|
const buttonVariants = {
|
||||||
|
variant: {
|
||||||
|
default: "bg-[#8D6B1D] text-white hover:bg-[#7A5E1A]",
|
||||||
|
destructive: "bg-red-500 text-white hover:bg-red-600",
|
||||||
|
outline: "border border-gray-300 bg-white hover:bg-gray-50 hover:text-gray-900",
|
||||||
|
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
|
||||||
|
ghost: "hover:bg-gray-100 hover:text-gray-900",
|
||||||
|
link: "text-[#8D6B1D] underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: keyof typeof buttonVariants.variant
|
||||||
|
size?: keyof typeof buttonVariants.size
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant = "default", size = "default", ...props }, ref) => {
|
||||||
|
const baseClasses = "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#8D6B1D] focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
const variantClasses = buttonVariants.variant[variant]
|
||||||
|
const sizeClasses = buttonVariants.size[size]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(baseClasses, variantClasses, sizeClasses, className)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button }
|
||||||
225
src/app/dashboard/page.tsx
Normal file
225
src/app/dashboard/page.tsx
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import useAuthStore from '../store/authStore'
|
||||||
|
import Header from '../components/nav/Header'
|
||||||
|
import Footer from '../components/Footer'
|
||||||
|
import {
|
||||||
|
ShoppingBagIcon,
|
||||||
|
UsersIcon,
|
||||||
|
UserCircleIcon,
|
||||||
|
StarIcon,
|
||||||
|
ChartBarIcon,
|
||||||
|
HeartIcon
|
||||||
|
} from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const user = useAuthStore(state => state.user)
|
||||||
|
|
||||||
|
// Redirect if not logged in
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
}, [user, router])
|
||||||
|
|
||||||
|
// Don't render if no user
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
||||||
|
<p className="text-[#4A4A4A]">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user name
|
||||||
|
const getUserName = () => {
|
||||||
|
if (user.firstName && user.lastName) {
|
||||||
|
return `${user.firstName} ${user.lastName}`
|
||||||
|
}
|
||||||
|
if (user.firstName) return user.firstName
|
||||||
|
if (user.email) return user.email.split('@')[0]
|
||||||
|
return 'User'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick actions
|
||||||
|
const quickActions = [
|
||||||
|
{
|
||||||
|
title: 'Browse Shop',
|
||||||
|
description: 'Explore sustainable products',
|
||||||
|
icon: ShoppingBagIcon,
|
||||||
|
href: '/shop',
|
||||||
|
color: 'bg-blue-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Join Community',
|
||||||
|
description: 'Connect with like-minded people',
|
||||||
|
icon: UsersIcon,
|
||||||
|
href: '/community',
|
||||||
|
color: 'bg-green-500'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Edit Profile',
|
||||||
|
description: 'Update your information',
|
||||||
|
icon: UserCircleIcon,
|
||||||
|
href: '/profile',
|
||||||
|
color: 'bg-purple-500'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// Stats (mock data for now)
|
||||||
|
const stats = [
|
||||||
|
{ label: 'Orders', value: '12', icon: ShoppingBagIcon },
|
||||||
|
{ label: 'Favorites', value: '8', icon: HeartIcon },
|
||||||
|
{ label: 'Gold Points', value: '250', icon: StarIcon },
|
||||||
|
{ label: 'Activity', value: '15', icon: ChartBarIcon }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-gray-50">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Welcome Section */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">
|
||||||
|
Welcome back, {getUserName()}! 👋
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mt-2">
|
||||||
|
Here's what's happening with your Profit Planet account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid - Tailwind UI Plus "With brand icon" */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{stats.map((stat, index) => {
|
||||||
|
// Define icon colors for each stat
|
||||||
|
const iconColors = [
|
||||||
|
'bg-blue-500', // Orders
|
||||||
|
'bg-red-500', // Favorites
|
||||||
|
'bg-yellow-500', // Gold Points
|
||||||
|
'bg-green-500' // Activity
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={index} className="relative overflow-hidden rounded-lg bg-white px-4 pb-12 pt-5 shadow sm:px-6 sm:pt-6">
|
||||||
|
<dt>
|
||||||
|
<div className={`absolute rounded-md ${iconColors[index]} p-3`}>
|
||||||
|
<stat.icon aria-hidden="true" className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<p className="ml-16 truncate text-sm font-medium text-gray-500">{stat.label}</p>
|
||||||
|
</dt>
|
||||||
|
<dd className="ml-16 flex items-baseline pb-6 sm:pb-7">
|
||||||
|
<p className="text-2xl font-semibold text-gray-900">{stat.value}</p>
|
||||||
|
<div className="absolute inset-x-0 bottom-0 bg-gray-50 px-4 py-4 sm:px-6">
|
||||||
|
<div className="text-sm">
|
||||||
|
<a href="#" className="font-medium text-[#8D6B1D] hover:text-[#7A5E1A]">
|
||||||
|
View details
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-4">Quick Actions</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{quickActions.map((action, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => router.push(action.href)}
|
||||||
|
className="bg-white rounded-lg p-6 shadow-sm border border-gray-200 hover:shadow-md transition-shadow text-left group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start">
|
||||||
|
<div className={`${action.color} rounded-lg p-3 group-hover:scale-105 transition-transform`}>
|
||||||
|
<action.icon className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 flex-1">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 group-hover:text-[#8D6B1D] transition-colors">
|
||||||
|
{action.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
{action.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gold Member Status */}
|
||||||
|
<div className="bg-gradient-to-r from-[#8D6B1D] to-[#B8860B] rounded-lg p-6 text-white mb-8">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<StarIcon className="h-12 w-12 text-yellow-300" />
|
||||||
|
<div className="ml-4">
|
||||||
|
<h2 className="text-2xl font-bold">Gold Member Status</h2>
|
||||||
|
<p className="text-yellow-100 mt-1">
|
||||||
|
Enjoy exclusive benefits and discounts
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<button className="bg-white/20 hover:bg-white/30 px-4 py-2 rounded-lg text-white font-medium transition-colors">
|
||||||
|
View Benefits
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-4">Recent Activity</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center py-3 border-b border-gray-100 last:border-b-0">
|
||||||
|
<div className="bg-green-100 rounded-full p-2">
|
||||||
|
<ShoppingBagIcon className="h-5 w-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">Order completed</p>
|
||||||
|
<p className="text-sm text-gray-600">Eco-friendly water bottle</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">2 days ago</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center py-3 border-b border-gray-100 last:border-b-0">
|
||||||
|
<div className="bg-blue-100 rounded-full p-2">
|
||||||
|
<HeartIcon className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">Added to favorites</p>
|
||||||
|
<p className="text-sm text-gray-600">Sustainable backpack</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">1 week ago</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center py-3">
|
||||||
|
<div className="bg-purple-100 rounded-full p-2">
|
||||||
|
<UsersIcon className="h-5 w-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">Joined community</p>
|
||||||
|
<p className="text-sm text-gray-600">Eco Warriors Group</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">2 weeks ago</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -3,6 +3,12 @@
|
|||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--background: #ffffff;
|
||||||
--foreground: #171717;
|
--foreground: #171717;
|
||||||
|
|
||||||
|
/* Brand Colors */
|
||||||
|
--color-brand-accent: #8D6B1D;
|
||||||
|
--color-brand-header: #0F172A;
|
||||||
|
--color-brand-text: #4A4A4A;
|
||||||
|
--color-brand-background: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
|||||||
10
src/app/i18n/config.ts
Normal file
10
src/app/i18n/config.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export type Language = 'en' | 'de';
|
||||||
|
|
||||||
|
export const DEFAULT_LANGUAGE: Language = 'en';
|
||||||
|
|
||||||
|
export const SUPPORTED_LANGUAGES: Language[] = ['en', 'de'];
|
||||||
|
|
||||||
|
export const LANGUAGE_NAMES = {
|
||||||
|
en: 'English',
|
||||||
|
de: 'Deutsch'
|
||||||
|
} as const;
|
||||||
48
src/app/i18n/translations/de.ts
Normal file
48
src/app/i18n/translations/de.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { Translations } from '../types';
|
||||||
|
|
||||||
|
export const de: Translations = {
|
||||||
|
home: {
|
||||||
|
title: 'Profit Planet',
|
||||||
|
tagline: 'Nachhaltige Produkte entdecken und handeln',
|
||||||
|
description: 'Tritt unserer Community von umweltbewussten Verbrauchern und Unternehmen bei. Handle mit nachhaltigen Produkten, sammle Belohnungen und mache einen positiven Einfluss auf unseren Planeten.',
|
||||||
|
features: {
|
||||||
|
sustainable: {
|
||||||
|
title: 'Nachhaltige Produkte',
|
||||||
|
description: 'Entdecke umweltfreundliche Produkte, die einen Unterschied für unseren Planeten machen.'
|
||||||
|
},
|
||||||
|
community: {
|
||||||
|
title: 'Aktive Community',
|
||||||
|
description: 'Vernetze dich mit Gleichgesinnten, denen Nachhaltigkeit wichtig ist.'
|
||||||
|
},
|
||||||
|
rewards: {
|
||||||
|
title: 'Belohnungen sammeln',
|
||||||
|
description: 'Erhalte Gold-Punkte für jeden nachhaltigen Kauf und jede Aktion.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
members: 'Aktive Mitglieder',
|
||||||
|
products: 'Öko-Produkte',
|
||||||
|
communities: 'Communities'
|
||||||
|
},
|
||||||
|
cta: {
|
||||||
|
getStarted: 'Jetzt starten',
|
||||||
|
learnMore: 'Mehr erfahren'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
company: 'Profit Planet GmbH',
|
||||||
|
rights: 'Alle Rechte vorbehalten.',
|
||||||
|
privacy: 'Datenschutz',
|
||||||
|
terms: 'AGB',
|
||||||
|
contact: 'Kontakt'
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
home: 'Home',
|
||||||
|
shop: 'Shop',
|
||||||
|
dashboard: 'Dashboard',
|
||||||
|
community: 'Community',
|
||||||
|
profile: 'Profil',
|
||||||
|
login: 'Anmelden',
|
||||||
|
logout: 'Abmelden'
|
||||||
|
}
|
||||||
|
};
|
||||||
48
src/app/i18n/translations/en.ts
Normal file
48
src/app/i18n/translations/en.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { Translations } from '../types';
|
||||||
|
|
||||||
|
export const en: Translations = {
|
||||||
|
home: {
|
||||||
|
title: 'Profit Planet',
|
||||||
|
tagline: 'Discover and trade sustainable products',
|
||||||
|
description: 'Join our community of eco-conscious consumers and businesses. Trade sustainable products, earn rewards, and make a positive impact on our planet.',
|
||||||
|
features: {
|
||||||
|
sustainable: {
|
||||||
|
title: 'Sustainable Products',
|
||||||
|
description: 'Discover eco-friendly products that make a difference for our planet.'
|
||||||
|
},
|
||||||
|
community: {
|
||||||
|
title: 'Active Community',
|
||||||
|
description: 'Connect with like-minded people who care about sustainability.'
|
||||||
|
},
|
||||||
|
rewards: {
|
||||||
|
title: 'Earn Rewards',
|
||||||
|
description: 'Get Gold Points for every sustainable purchase and action.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
members: 'Active Members',
|
||||||
|
products: 'Eco Products',
|
||||||
|
communities: 'Communities'
|
||||||
|
},
|
||||||
|
cta: {
|
||||||
|
getStarted: 'Get Started',
|
||||||
|
learnMore: 'Learn More'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
company: 'Profit Planet GmbH',
|
||||||
|
rights: 'All rights reserved.',
|
||||||
|
privacy: 'Privacy Policy',
|
||||||
|
terms: 'Terms of Service',
|
||||||
|
contact: 'Contact'
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
home: 'Home',
|
||||||
|
shop: 'Shop',
|
||||||
|
dashboard: 'Dashboard',
|
||||||
|
community: 'Community',
|
||||||
|
profile: 'Profile',
|
||||||
|
login: 'Login',
|
||||||
|
logout: 'Logout'
|
||||||
|
}
|
||||||
|
};
|
||||||
46
src/app/i18n/types.ts
Normal file
46
src/app/i18n/types.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
export interface Translations {
|
||||||
|
home: {
|
||||||
|
title: string;
|
||||||
|
tagline: string;
|
||||||
|
description: string;
|
||||||
|
features: {
|
||||||
|
sustainable: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
community: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
rewards: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
stats: {
|
||||||
|
members: string;
|
||||||
|
products: string;
|
||||||
|
communities: string;
|
||||||
|
};
|
||||||
|
cta: {
|
||||||
|
getStarted: string;
|
||||||
|
learnMore: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
footer: {
|
||||||
|
company: string;
|
||||||
|
rights: string;
|
||||||
|
privacy: string;
|
||||||
|
terms: string;
|
||||||
|
contact: string;
|
||||||
|
};
|
||||||
|
nav: {
|
||||||
|
home: string;
|
||||||
|
shop: string;
|
||||||
|
dashboard: string;
|
||||||
|
community: string;
|
||||||
|
profile: string;
|
||||||
|
login: string;
|
||||||
|
logout: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
52
src/app/i18n/useTranslation.tsx
Normal file
52
src/app/i18n/useTranslation.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||||
|
import { Language, DEFAULT_LANGUAGE } from './config';
|
||||||
|
import { en } from './translations/en';
|
||||||
|
import { de } from './translations/de';
|
||||||
|
|
||||||
|
const translations = {
|
||||||
|
en,
|
||||||
|
de
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface I18nContextType {
|
||||||
|
language: Language;
|
||||||
|
setLanguage: (lang: Language) => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const I18nContext = createContext<I18nContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
interface I18nProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function I18nProvider({ children }: I18nProviderProps) {
|
||||||
|
const [language, setLanguage] = useState<Language>(DEFAULT_LANGUAGE);
|
||||||
|
|
||||||
|
const t = (key: string): string => {
|
||||||
|
const keys = key.split('.');
|
||||||
|
let value: any = translations[language];
|
||||||
|
|
||||||
|
for (const k of keys) {
|
||||||
|
value = value?.[k];
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof value === 'string' ? value : key;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<I18nContext.Provider value={{ language, setLanguage, t }}>
|
||||||
|
{children}
|
||||||
|
</I18nContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTranslation() {
|
||||||
|
const context = useContext(I18nContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useTranslation must be used within an I18nProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import '@tailwindplus/elements';
|
||||||
|
import ClientWrapper from './ClientWrapper';
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@ -27,7 +29,9 @@ export default function RootLayout({
|
|||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
<ClientWrapper>
|
||||||
{children}
|
{children}
|
||||||
|
</ClientWrapper>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
369
src/app/login/components/LoginForm.tsx
Normal file
369
src/app/login/components/LoginForm.tsx
Normal file
@ -0,0 +1,369 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'
|
||||||
|
import { useLogin } from '../hooks/useLogin'
|
||||||
|
|
||||||
|
export default function LoginForm() {
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [showBall, setShowBall] = useState(true)
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
rememberMe: false
|
||||||
|
})
|
||||||
|
const router = useRouter()
|
||||||
|
const { login, error, setError, loading } = useLogin()
|
||||||
|
|
||||||
|
// Responsive ball visibility
|
||||||
|
useState(() => {
|
||||||
|
const handleResize = () => {
|
||||||
|
setShowBall(window.innerWidth >= 768)
|
||||||
|
}
|
||||||
|
handleResize()
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
return () => window.removeEventListener('resize', handleResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value, type, checked } = e.target
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: type === 'checkbox' ? checked : value
|
||||||
|
}))
|
||||||
|
setError('') // Clear error when user starts typing
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
if (!formData.email.trim()) {
|
||||||
|
setError('E-Mail-Adresse ist erforderlich')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||||
|
setError('Bitte gib eine gültige E-Mail-Adresse ein')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.password.trim()) {
|
||||||
|
setError('Passwort ist erforderlich')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.password.length < 6) {
|
||||||
|
setError('Passwort muss mindestens 6 Zeichen lang sein')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!validateForm()) return
|
||||||
|
|
||||||
|
await login({
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
rememberMe: formData.rememberMe
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full flex justify-center items-center min-h-screen py-8 relative"
|
||||||
|
style={{
|
||||||
|
minHeight: 'calc(100vh - 100px)',
|
||||||
|
paddingTop: isMobile ? '0.25rem' : '5rem',
|
||||||
|
paddingBottom: isMobile ? '2.5rem' : '2.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-2xl shadow-2xl flex flex-col items-center relative border-t-4 border-[#8D6B1D]"
|
||||||
|
style={{
|
||||||
|
width: isMobile ? '98vw' : '40vw',
|
||||||
|
maxWidth: isMobile ? 'none' : '700px',
|
||||||
|
minWidth: isMobile ? '0' : '400px',
|
||||||
|
minHeight: isMobile ? '320px' : '320px',
|
||||||
|
padding: isMobile ? '0.5rem' : '2rem',
|
||||||
|
marginTop: isMobile ? '0.5rem' : undefined,
|
||||||
|
transform: isMobile ? undefined : 'scale(0.85)',
|
||||||
|
transformOrigin: 'top center',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'flex-start'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Animated Ball - Desktop Only */}
|
||||||
|
{showBall && !isMobile && (
|
||||||
|
<div className="absolute -top-16 left-1/2 -translate-x-1/2 w-28 z-20">
|
||||||
|
<div className="w-28 h-28 rounded-full bg-gradient-to-br from-[#8D6B1D] via-[#A67C20] to-[#C49225] flex items-center justify-center shadow-xl border-4 border-white relative">
|
||||||
|
<svg className="w-20 h-20 text-[#F4E7D1]" viewBox="0 0 64 64" fill="none">
|
||||||
|
<circle cx="32" cy="32" r="20" fill="currentColor" />
|
||||||
|
<ellipse cx="32" cy="38" rx="16" ry="5" fill="#8D6B1D" fillOpacity=".10" />
|
||||||
|
<ellipse cx="32" cy="26" rx="10" ry="4" fill="#8D6B1D" fillOpacity=".08" />
|
||||||
|
<circle cx="40" cy="26" r="3" fill="#8D6B1D" fillOpacity=".5" />
|
||||||
|
</svg>
|
||||||
|
{/* Orbiting balls */}
|
||||||
|
<span className="absolute left-1/2 top-1/2 w-0 h-0">
|
||||||
|
<span className="block absolute animate-orbit-1" style={{ width: 0, height: 0 }}>
|
||||||
|
<span className="block w-3 h-3 bg-[#8D6B1D] rounded-full shadow-lg" style={{ transform: 'translateX(44px)' }}></span>
|
||||||
|
</span>
|
||||||
|
<span className="block absolute animate-orbit-2" style={{ width: 0, height: 0 }}>
|
||||||
|
<span className="block w-2.5 h-2.5 bg-[#A67C20] rounded-full shadow-md" style={{ transform: 'translateX(-36px)' }}></span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: isMobile ? '0.5rem' : '1.5rem',
|
||||||
|
marginBottom: isMobile ? '1.5rem' : '2rem',
|
||||||
|
width: '100%'
|
||||||
|
}}>
|
||||||
|
<h1
|
||||||
|
className="mb-2 text-center text-4xl font-extrabold text-[#0F172A] tracking-tight drop-shadow-lg"
|
||||||
|
style={{
|
||||||
|
fontSize: isMobile ? '2rem' : undefined,
|
||||||
|
marginTop: isMobile ? '0.5rem' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Profit Planet
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="mb-8 text-center text-lg text-[#8D6B1D] font-medium"
|
||||||
|
style={{
|
||||||
|
fontSize: isMobile ? '0.95rem' : undefined,
|
||||||
|
marginBottom: isMobile ? '1rem' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Welcome back! Login to continue.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="space-y-7 w-full"
|
||||||
|
style={{
|
||||||
|
gap: isMobile ? '0.75rem' : undefined,
|
||||||
|
}}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
>
|
||||||
|
{/* Email Field */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="block text-base font-semibold text-[#0F172A] mb-1"
|
||||||
|
style={{
|
||||||
|
fontSize: isMobile ? '0.875rem' : undefined,
|
||||||
|
marginBottom: isMobile ? '0.25rem' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
E-Mail-Adresse
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="appearance-none block w-full px-4 py-3 border border-gray-300 rounded-lg placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-[#8D6B1D] text-base bg-white text-[#0F172A] transition"
|
||||||
|
style={{
|
||||||
|
fontSize: isMobile ? '0.875rem' : undefined,
|
||||||
|
padding: isMobile ? '0.4rem 0.75rem' : undefined,
|
||||||
|
}}
|
||||||
|
placeholder="deine@email.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Field */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-base font-semibold text-[#0F172A] mb-1"
|
||||||
|
style={{
|
||||||
|
fontSize: isMobile ? '0.875rem' : undefined,
|
||||||
|
marginBottom: isMobile ? '0.25rem' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="appearance-none block w-full px-4 py-3 pr-12 border border-gray-300 rounded-lg placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-[#8D6B1D] text-base bg-white text-[#0F172A] transition"
|
||||||
|
style={{
|
||||||
|
fontSize: isMobile ? '0.875rem' : undefined,
|
||||||
|
padding: isMobile ? '0.4rem 2.5rem 0.4rem 0.75rem' : '0.75rem 3rem 0.75rem 1rem',
|
||||||
|
}}
|
||||||
|
placeholder="Dein Passwort"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeSlashIcon className="h-5 w-5 text-gray-400 hover:text-[#8D6B1D] transition-colors" />
|
||||||
|
) : (
|
||||||
|
<EyeIcon className="h-5 w-5 text-gray-400 hover:text-[#8D6B1D] transition-colors" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remember Me & Show Password */}
|
||||||
|
<div className="mt-2 flex items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
id="rememberMe"
|
||||||
|
name="rememberMe"
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.rememberMe}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="h-4 w-4 text-[#8D6B1D] border-2 border-gray-300 rounded focus:ring-[#8D6B1D] focus:ring-2"
|
||||||
|
/>
|
||||||
|
<label htmlFor="rememberMe" className="ml-2 text-sm text-[#4A4A4A]">
|
||||||
|
Angemeldet bleiben
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
id="show-password"
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 border-2 border-gray-300 rounded focus:ring-[#8D6B1D] focus:ring-2"
|
||||||
|
checked={showPassword}
|
||||||
|
onChange={(e) => setShowPassword(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="show-password" className="ml-2 text-sm text-[#4A4A4A]">
|
||||||
|
Passwort anzeigen
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-500 text-sm bg-red-50 border border-red-200 rounded-lg p-3">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className={`w-full py-3 px-6 rounded-lg shadow-md text-base font-bold text-white transition-all duration-200 transform hover:-translate-y-0.5 ${
|
||||||
|
loading
|
||||||
|
? 'bg-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-gradient-to-r from-[#8D6B1D] via-[#A67C20] to-[#C49225] hover:from-[#7A5E1A] hover:to-[#B8851F] focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:ring-offset-2'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
fontSize: isMobile ? '0.9rem' : undefined,
|
||||||
|
padding: isMobile ? '0.6rem 1rem' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
Anmeldung läuft...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'Anmelden'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Forgot Password */}
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-[#8D6B1D] hover:text-[#7A5E1A] hover:underline text-sm font-medium transition-colors"
|
||||||
|
onClick={() => router.push("/password-reset")}
|
||||||
|
>
|
||||||
|
Passwort vergessen?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Registration Section */}
|
||||||
|
<div
|
||||||
|
className="mt-10 w-full"
|
||||||
|
style={{
|
||||||
|
marginTop: isMobile ? '1rem' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-gray-200"></div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="relative flex justify-center text-base"
|
||||||
|
style={{
|
||||||
|
fontSize: isMobile ? '0.875rem' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<a href="/register" className="px-3 bg-white text-[#8D6B1D]">Noch kein Account?</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mt-7 text-center"
|
||||||
|
style={{
|
||||||
|
marginTop: isMobile ? '0.75rem' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-base text-[#4A4A4A]"
|
||||||
|
style={{
|
||||||
|
fontSize: isMobile ? '0.8rem' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Profit Planet is available by invitation only.
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-base text-[#8D6B1D] mt-2"
|
||||||
|
style={{
|
||||||
|
fontSize: isMobile ? '0.8rem' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Contact us for an invitation!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CSS Animations */}
|
||||||
|
<style jsx>{`
|
||||||
|
@keyframes orbit-1 {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
@keyframes orbit-2 {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(-360deg); }
|
||||||
|
}
|
||||||
|
.animate-orbit-1 {
|
||||||
|
animation: orbit-1 3s linear infinite;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
.animate-orbit-2 {
|
||||||
|
animation: orbit-2 4s linear infinite;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
116
src/app/login/hooks/useLogin.ts
Normal file
116
src/app/login/hooks/useLogin.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import useAuthStore from '../../store/authStore'
|
||||||
|
|
||||||
|
export interface LoginCredentials {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
rememberMe?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogin() {
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const setAccessToken = useAuthStore(state => state.setAccessToken)
|
||||||
|
const setUser = useAuthStore(state => state.setUser)
|
||||||
|
|
||||||
|
const login = async (credentials: LoginCredentials) => {
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Login attempt:', {
|
||||||
|
email: credentials.email,
|
||||||
|
rememberMe: credentials.rememberMe
|
||||||
|
})
|
||||||
|
|
||||||
|
// Make actual API call to backend
|
||||||
|
const loginUrl = `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/login`
|
||||||
|
console.log('Calling login API:', loginUrl)
|
||||||
|
|
||||||
|
const response = await fetch(loginUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include', // Include cookies for refresh token
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: credentials.email,
|
||||||
|
password: credentials.password,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Login response status:', response.status)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Handle HTTP errors
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error('Invalid credentials')
|
||||||
|
} else if (response.status === 404) {
|
||||||
|
throw new Error('Account not found')
|
||||||
|
} else if (response.status === 423) {
|
||||||
|
throw new Error('Account locked')
|
||||||
|
} else {
|
||||||
|
throw new Error('Login failed. Please try again.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
console.log('Login response data:', data)
|
||||||
|
|
||||||
|
if (data.success && data.accessToken && data.user) {
|
||||||
|
// Update auth store
|
||||||
|
setAccessToken(data.accessToken)
|
||||||
|
setUser(data.user)
|
||||||
|
|
||||||
|
// Store session info if remember me is checked
|
||||||
|
if (credentials.rememberMe) {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
sessionStorage.setItem('userType', data.user.userType)
|
||||||
|
sessionStorage.setItem('role', data.user.role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Login successful:', data.user)
|
||||||
|
|
||||||
|
// Redirect to dashboard
|
||||||
|
router.push('/dashboard')
|
||||||
|
|
||||||
|
return { success: true, user: data.user }
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message || 'Login failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('❌ Login error:', err)
|
||||||
|
|
||||||
|
// Handle specific error cases
|
||||||
|
if (err.message?.includes('Invalid credentials')) {
|
||||||
|
setError('E-Mail oder Passwort falsch')
|
||||||
|
} else if (err.message?.includes('Account not found')) {
|
||||||
|
setError('Kein Account mit dieser E-Mail-Adresse gefunden')
|
||||||
|
} else if (err.message?.includes('Account locked')) {
|
||||||
|
setError('Account wurde gesperrt. Kontaktiere den Support.')
|
||||||
|
} else if (err.message?.includes('Failed to fetch')) {
|
||||||
|
setError('Verbindung zum Server fehlgeschlagen. Bitte versuche es später erneut.')
|
||||||
|
} else {
|
||||||
|
setError(err.message || 'Anmeldung fehlgeschlagen. Bitte versuche es erneut.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
login,
|
||||||
|
error,
|
||||||
|
setError,
|
||||||
|
loading
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/app/login/page.tsx
Normal file
69
src/app/login/page.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import LoginForm from './components/LoginForm'
|
||||||
|
import PageLayout from '../components/PageLayout'
|
||||||
|
import useAuthStore from '../store/authStore'
|
||||||
|
import GlobalAnimatedBackground from '../background/GlobalAnimatedBackground'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [showBackground, setShowBackground] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
const user = useAuthStore(state => state.user)
|
||||||
|
|
||||||
|
// Check if user is already logged in
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
router.push('/dashboard')
|
||||||
|
}
|
||||||
|
}, [user, router])
|
||||||
|
|
||||||
|
// Responsive background detection
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => setShowBackground(window.innerWidth >= 768)
|
||||||
|
handleResize() // Initial check
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
return () => window.removeEventListener('resize', handleResize)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Don't render if user is already logged in
|
||||||
|
if (user) {
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
||||||
|
<p className="text-[#4A4A4A]">You are already logged in. Redirecting...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout showFooter={false}>
|
||||||
|
<div className="relative w-full flex flex-col min-h-screen">
|
||||||
|
{/* Animated background for desktop */}
|
||||||
|
{showBackground && (
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
|
<GlobalAnimatedBackground />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative z-10 flex-1 flex items-center justify-center py-12 px-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<LoginForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer for mobile */}
|
||||||
|
<div className="relative z-10 md:hidden">
|
||||||
|
<div className="text-center py-4 text-sm text-[#4A4A4A]">
|
||||||
|
© 2024 Profit Planet. Alle Rechte vorbehalten.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
src/app/login/schema/loginSchema.ts
Normal file
12
src/app/login/schema/loginSchema.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import * as yup from 'yup';
|
||||||
|
|
||||||
|
export const loginSchema = yup.object().shape({
|
||||||
|
email: yup
|
||||||
|
.string()
|
||||||
|
.required('validation.email.required')
|
||||||
|
.email('validation.email.invalid'),
|
||||||
|
password: yup
|
||||||
|
.string()
|
||||||
|
.required('validation.password.required')
|
||||||
|
.min(8, 'validation.password.minLength')
|
||||||
|
});
|
||||||
189
src/app/page.tsx
189
src/app/page.tsx
@ -1,103 +1,108 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import GlobalAnimatedBackground from './background/GlobalAnimatedBackground';
|
||||||
|
import Footer from './components/Footer';
|
||||||
|
import Header from './components/nav/Header';
|
||||||
|
import { useTranslation } from './i18n/useTranslation';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
const router = useRouter();
|
||||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
const { t } = useTranslation();
|
||||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={180}
|
|
||||||
height={38}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
|
||||||
<li className="mb-2 tracking-[-.01em]">
|
|
||||||
Get started by editing{" "}
|
|
||||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
|
||||||
src/app/page.tsx
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</li>
|
|
||||||
<li className="tracking-[-.01em]">
|
|
||||||
Save and see your changes instantly.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
return (
|
||||||
<a
|
<div className="min-h-screen flex flex-col bg-white">
|
||||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
{/* Header */}
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<Header />
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
{/* Main Content */}
|
||||||
|
<main className="relative flex-1 flex flex-col items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-7xl mx-auto text-center">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<div className="space-y-8 sm:space-y-12">
|
||||||
|
{/* Logo/Title */}
|
||||||
|
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-extrabold text-[#0F172A] tracking-tight drop-shadow-lg">
|
||||||
|
{t('home.title')}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Tagline */}
|
||||||
|
<p className="text-xl sm:text-2xl text-[#FFFFFF] max-w-3xl mx-auto">
|
||||||
|
{t('home.tagline')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Feature Highlights */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mt-16 max-w-5xl mx-auto">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
title: t('home.features.sustainable.title'),
|
||||||
|
description: t('home.features.sustainable.description')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('home.features.community.title'),
|
||||||
|
description: t('home.features.community.description')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('home.features.rewards.title'),
|
||||||
|
description: t('home.features.rewards.description')
|
||||||
|
}
|
||||||
|
].map((feature, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="bg-white/80 backdrop-blur-lg rounded-xl p-6 shadow-lg border border-[#8D6B1D]/20 hover:border-[#8D6B1D]/30 transition-all duration-300 hover:transform hover:-translate-y-1"
|
||||||
>
|
>
|
||||||
<Image
|
<h3 className="text-lg font-semibold text-[#0F172A] mb-2">
|
||||||
className="dark:invert"
|
{feature.title}
|
||||||
src="/vercel.svg"
|
</h3>
|
||||||
alt="Vercel logomark"
|
<p className="text-[#4A4A4A]">
|
||||||
width={20}
|
{feature.description}
|
||||||
height={20}
|
</p>
|
||||||
/>
|
</div>
|
||||||
Deploy now
|
))}
|
||||||
</a>
|
</div>
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
{/* Community Stats */}
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<div className="flex justify-center gap-8 mt-12">
|
||||||
target="_blank"
|
<div className="text-center">
|
||||||
rel="noopener noreferrer"
|
<div className="text-4xl font-bold text-[#FFFFFF]">10k+</div>
|
||||||
|
<div className="text-sm text-[#FFFFFF]">{t('home.stats.members')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-4xl font-bold text-[#FFFFFF]">50k+</div>
|
||||||
|
<div className="text-sm text-[#FFFFFF]">{t('home.stats.products')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-4xl font-bold text-[#FFFFFF]">100</div>
|
||||||
|
<div className="text-sm text-[#FFFFFF]">{t('home.stats.communities')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA Buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center mt-12">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/shop')}
|
||||||
|
className="px-8 py-3 rounded-lg bg-[#8D6B1D] text-white font-semibold hover:bg-[#8D6B1D]/90 transition-colors duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
|
||||||
>
|
>
|
||||||
Read our docs
|
{t('home.cta.getStarted')}
|
||||||
</a>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/register')}
|
||||||
|
className="px-8 py-3 rounded-lg bg-white text-[#8D6B1D] font-semibold hover:bg-[#8D6B1D]/5 transition-colors duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 border border-[#8D6B1D]/20"
|
||||||
|
>
|
||||||
|
{t('home.cta.learnMore')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
|
||||||
<a
|
{/* Footer */}
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
<div className="relative z-10">
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<Footer />
|
||||||
target="_blank"
|
</div>
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
{/* Decorative Elements */}
|
||||||
<Image
|
<div className="fixed bottom-0 left-0 w-full h-32 bg-gradient-to-t from-white/50 to-transparent z-[1]" />
|
||||||
aria-hidden
|
|
||||||
src="/file.svg"
|
|
||||||
alt="File icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Learn
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/window.svg"
|
|
||||||
alt="Window icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Examples
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/globe.svg"
|
|
||||||
alt="Globe icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Go to nextjs.org →
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
244
src/app/profile/page.tsx
Normal file
244
src/app/profile/page.tsx
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import useAuthStore from '../store/authStore'
|
||||||
|
import Header from '../components/nav/Header'
|
||||||
|
import Footer from '../components/Footer'
|
||||||
|
import {
|
||||||
|
UserCircleIcon,
|
||||||
|
EnvelopeIcon,
|
||||||
|
PhoneIcon,
|
||||||
|
MapPinIcon,
|
||||||
|
PencilIcon,
|
||||||
|
CheckCircleIcon
|
||||||
|
} from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const user = useAuthStore(state => state.user)
|
||||||
|
|
||||||
|
// Redirect if not logged in
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
}, [user, router])
|
||||||
|
|
||||||
|
// Don't render if no user
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
||||||
|
<p className="text-[#4A4A4A]">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock user data for display
|
||||||
|
const profileData = {
|
||||||
|
firstName: 'Admin',
|
||||||
|
lastName: 'User',
|
||||||
|
email: user.email || 'office@profit-planet.com',
|
||||||
|
phone: '+49 123 456 789',
|
||||||
|
address: 'Musterstraße 123, 12345 Berlin',
|
||||||
|
joinDate: 'Oktober 2024',
|
||||||
|
memberStatus: 'Gold Member',
|
||||||
|
profileComplete: 95
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-gray-50">
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Profile Settings</h1>
|
||||||
|
<p className="text-gray-600 mt-2">
|
||||||
|
Manage your account information and preferences
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile Completion */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Profile Completion</h2>
|
||||||
|
<span className="text-sm font-medium text-[#8D6B1D]">{profileData.profileComplete}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-r from-[#8D6B1D] to-[#C49225] h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${profileData.profileComplete}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 mt-2">
|
||||||
|
Complete your profile to unlock all features
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{/* Profile Information */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Basic Information */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Basic Information</h2>
|
||||||
|
<button className="flex items-center text-[#8D6B1D] hover:text-[#7A5E1A] transition-colors">
|
||||||
|
<PencilIcon className="h-4 w-4 mr-1" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
First Name
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center p-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||||
|
<UserCircleIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
|
<span className="text-gray-900">{profileData.firstName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Last Name
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center p-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||||
|
<UserCircleIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
|
<span className="text-gray-900">{profileData.lastName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center p-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||||
|
<EnvelopeIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
|
<span className="text-gray-900">{profileData.email}</span>
|
||||||
|
<CheckCircleIcon className="h-5 w-5 text-green-500 ml-auto" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Phone Number
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center p-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||||
|
<PhoneIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
|
<span className="text-gray-900">{profileData.phone}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Address
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center p-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||||
|
<MapPinIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
|
<span className="text-gray-900">{profileData.address}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account Settings */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-6">Account Settings</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between py-3 border-b border-gray-100">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Email Notifications</p>
|
||||||
|
<p className="text-sm text-gray-600">Receive updates about orders and promotions</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" className="sr-only peer" defaultChecked />
|
||||||
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#8D6B1D]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#8D6B1D]"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-3 border-b border-gray-100">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">SMS Notifications</p>
|
||||||
|
<p className="text-sm text-gray-600">Get text messages for important updates</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" className="sr-only peer" />
|
||||||
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#8D6B1D]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#8D6B1D]"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between py-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">Two-Factor Authentication</p>
|
||||||
|
<p className="text-sm text-gray-600">Add extra security to your account</p>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 text-sm font-medium text-[#8D6B1D] border border-[#8D6B1D] rounded-lg hover:bg-[#8D6B1D]/10 transition-colors">
|
||||||
|
Enable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Account Status */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Account Status</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-600">Member Since</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{profileData.joinDate}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-600">Status</span>
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gradient-to-r from-[#8D6B1D] to-[#C49225] text-white">
|
||||||
|
{profileData.memberStatus}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-600">Profile</span>
|
||||||
|
<span className="text-sm font-medium text-green-600">Verified</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Quick Actions</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/dashboard')}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Back to Dashboard
|
||||||
|
</button>
|
||||||
|
<button className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded-lg transition-colors">
|
||||||
|
Download Account Data
|
||||||
|
</button>
|
||||||
|
<button className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
||||||
|
Delete Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
633
src/app/register/components/RegisterForm.tsx
Normal file
633
src/app/register/components/RegisterForm.tsx
Normal file
@ -0,0 +1,633 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
|
interface RegisterFormProps {
|
||||||
|
mode: 'personal' | 'company'
|
||||||
|
setMode: (mode: 'personal' | 'company') => void
|
||||||
|
refToken: string | null
|
||||||
|
onRegistered: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersonalFormData {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
email: string
|
||||||
|
confirmEmail: string
|
||||||
|
password: string
|
||||||
|
confirmPassword: string
|
||||||
|
phoneNumber: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompanyFormData {
|
||||||
|
companyName: string
|
||||||
|
companyEmail: string
|
||||||
|
confirmCompanyEmail: string
|
||||||
|
companyPhone: string
|
||||||
|
contactPersonName: string
|
||||||
|
contactPersonPhone: string
|
||||||
|
password: string
|
||||||
|
confirmPassword: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RegisterForm({
|
||||||
|
mode,
|
||||||
|
setMode,
|
||||||
|
refToken,
|
||||||
|
onRegistered
|
||||||
|
}: RegisterFormProps) {
|
||||||
|
// Personal form state
|
||||||
|
const [personalForm, setPersonalForm] = useState<PersonalFormData>({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
confirmEmail: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
phoneNumber: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Company form state
|
||||||
|
const [companyForm, setCompanyForm] = useState<CompanyFormData>({
|
||||||
|
companyName: '',
|
||||||
|
companyEmail: '',
|
||||||
|
confirmCompanyEmail: '',
|
||||||
|
companyPhone: '',
|
||||||
|
contactPersonName: '',
|
||||||
|
contactPersonPhone: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const [showPersonalPassword, setShowPersonalPassword] = useState(false)
|
||||||
|
const [showCompanyPassword, setShowCompanyPassword] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [formFade, setFormFade] = useState('fade-in')
|
||||||
|
|
||||||
|
// Animate form when mode changes
|
||||||
|
useEffect(() => {
|
||||||
|
setFormFade('fade-out')
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setFormFade('fade-in')
|
||||||
|
}, 180)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}, [mode])
|
||||||
|
|
||||||
|
// Add fade CSS
|
||||||
|
useEffect(() => {
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.innerHTML = `
|
||||||
|
.fade-in {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
transition: opacity 180ms, transform 180ms;
|
||||||
|
}
|
||||||
|
.fade-out {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
transition: opacity 180ms, transform 180ms;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
document.head.appendChild(style)
|
||||||
|
return () => {
|
||||||
|
document.head.removeChild(style)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Reset forms when switching modes
|
||||||
|
useEffect(() => {
|
||||||
|
setError('')
|
||||||
|
}, [mode])
|
||||||
|
|
||||||
|
// Validation helpers
|
||||||
|
const validatePersonalForm = (): boolean => {
|
||||||
|
if (!personalForm.firstName.trim() || !personalForm.lastName.trim() ||
|
||||||
|
!personalForm.email.trim() || !personalForm.confirmEmail.trim() ||
|
||||||
|
!personalForm.password.trim() || !personalForm.confirmPassword.trim()) {
|
||||||
|
setError('Alle Felder sind erforderlich')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (personalForm.email !== personalForm.confirmEmail) {
|
||||||
|
setError('E-Mail-Adressen stimmen nicht überein')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (personalForm.password !== personalForm.confirmPassword) {
|
||||||
|
setError('Passwörter stimmen nicht überein')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/.test(personalForm.password)) {
|
||||||
|
setError('Passwort muss mindestens 8 Zeichen lang sein und Groß-, Kleinbuchstaben, Ziffern und Sonderzeichen enthalten')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateCompanyForm = (): boolean => {
|
||||||
|
if (!companyForm.companyName.trim() || !companyForm.companyEmail.trim() ||
|
||||||
|
!companyForm.confirmCompanyEmail.trim() || !companyForm.contactPersonName.trim() ||
|
||||||
|
!companyForm.password.trim() || !companyForm.confirmPassword.trim()) {
|
||||||
|
setError('Alle Felder sind erforderlich')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (companyForm.companyEmail !== companyForm.confirmCompanyEmail) {
|
||||||
|
setError('E-Mail-Adressen stimmen nicht überein')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (companyForm.password !== companyForm.confirmPassword) {
|
||||||
|
setError('Passwörter stimmen nicht überein')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/.test(companyForm.password)) {
|
||||||
|
setError('Passwort muss mindestens 8 Zeichen lang sein und Groß-, Kleinbuchstaben, Ziffern und Sonderzeichen enthalten')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit handlers
|
||||||
|
const handlePersonalSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (loading) return
|
||||||
|
|
||||||
|
if (!validatePersonalForm()) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Implement API call
|
||||||
|
console.log('Personal registration:', { ...personalForm, refToken })
|
||||||
|
|
||||||
|
// Simulate API delay
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
|
// For now, just call onRegistered
|
||||||
|
onRegistered()
|
||||||
|
} catch (error) {
|
||||||
|
setError('Registrierung fehlgeschlagen. Bitte versuche es erneut.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCompanySubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (loading) return
|
||||||
|
|
||||||
|
if (!validateCompanyForm()) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Implement API call
|
||||||
|
console.log('Company registration:', { ...companyForm, refToken })
|
||||||
|
|
||||||
|
// Simulate API delay
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
|
// For now, just call onRegistered
|
||||||
|
onRegistered()
|
||||||
|
} catch (error) {
|
||||||
|
setError('Registrierung fehlgeschlagen. Bitte versuche es erneut.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input change handlers
|
||||||
|
const handlePersonalChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target
|
||||||
|
setPersonalForm(prev => ({ ...prev, [name]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCompanyChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target
|
||||||
|
setCompanyForm(prev => ({ ...prev, [name]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password strength indicator
|
||||||
|
const getPasswordStrength = (password: string) => {
|
||||||
|
let strength = 0
|
||||||
|
if (password.length >= 8) strength++
|
||||||
|
if (/[a-z]/.test(password)) strength++
|
||||||
|
if (/[A-Z]/.test(password)) strength++
|
||||||
|
if (/\d/.test(password)) strength++
|
||||||
|
if (/[\W_]/.test(password)) strength++
|
||||||
|
return strength
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPasswordStrength = (password: string) => {
|
||||||
|
const strength = getPasswordStrength(password)
|
||||||
|
const rules = [
|
||||||
|
{ test: password.length >= 8, text: 'Mindestens 8 Zeichen' },
|
||||||
|
{ test: /[a-z]/.test(password), text: 'Kleinbuchstaben (a-z)' },
|
||||||
|
{ test: /[A-Z]/.test(password), text: 'Großbuchstaben (A-Z)' },
|
||||||
|
{ test: /\d/.test(password), text: 'Ziffern (0-9)' },
|
||||||
|
{ test: /[\W_]/.test(password), text: 'Sonderzeichen (!@#$...)' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="text-sm text-[#4A4A4A] mb-2">Passwort-Anforderungen:</div>
|
||||||
|
<ul className="text-sm space-y-1">
|
||||||
|
{rules.map((rule, index) => (
|
||||||
|
<li key={index} className={`flex items-center gap-2 ${rule.test ? 'text-green-600' : 'text-gray-500'}`}>
|
||||||
|
<span>{rule.test ? '✓' : '○'}</span>
|
||||||
|
<span>{rule.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-4xl mx-auto bg-white rounded-2xl shadow-2xl px-6 py-8 sm:px-12 sm:py-10">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-extrabold text-[#0F172A] mb-2">
|
||||||
|
Registrierung für Profit Planet
|
||||||
|
</h2>
|
||||||
|
{refToken && (
|
||||||
|
<p className="text-base sm:text-sm text-[#8D6B1D] font-medium">
|
||||||
|
Du wurdest eingeladen!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode Toggle */}
|
||||||
|
<div className="flex justify-center mb-8">
|
||||||
|
<div className="bg-gray-100 p-1 rounded-lg">
|
||||||
|
<button
|
||||||
|
className={`px-6 py-2 rounded-md font-semibold text-sm transition-all duration-200 ${
|
||||||
|
mode === 'personal'
|
||||||
|
? 'bg-[#8D6B1D] text-white shadow-sm'
|
||||||
|
: 'bg-transparent text-[#4A4A4A] hover:text-[#8D6B1D]'
|
||||||
|
}`}
|
||||||
|
onClick={() => setMode('personal')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Privatperson
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`px-6 py-2 rounded-md font-semibold text-sm transition-all duration-200 ${
|
||||||
|
mode === 'company'
|
||||||
|
? 'bg-[#8D6B1D] text-white shadow-sm'
|
||||||
|
: 'bg-transparent text-[#4A4A4A] hover:text-[#8D6B1D]'
|
||||||
|
}`}
|
||||||
|
onClick={() => setMode('company')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Unternehmen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p className="text-red-600 text-sm font-medium">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Forms */}
|
||||||
|
<div className={formFade}>
|
||||||
|
{mode === 'personal' ? (
|
||||||
|
<form onSubmit={handlePersonalSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="firstName" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
|
Vorname *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
value={personalForm.firstName}
|
||||||
|
onChange={handlePersonalChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="lastName" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
|
Nachname *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
|
value={personalForm.lastName}
|
||||||
|
onChange={handlePersonalChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
|
E-Mail-Adresse *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
value={personalForm.email}
|
||||||
|
onChange={handlePersonalChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmEmail" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
|
E-Mail bestätigen *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="confirmEmail"
|
||||||
|
name="confirmEmail"
|
||||||
|
value={personalForm.confirmEmail}
|
||||||
|
onChange={handlePersonalChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="phoneNumber" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
|
Telefonnummer
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="phoneNumber"
|
||||||
|
name="phoneNumber"
|
||||||
|
value={personalForm.phoneNumber}
|
||||||
|
onChange={handlePersonalChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors"
|
||||||
|
placeholder="+49 123 456 7890"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
|
Passwort *
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPersonalPassword ? 'text' : 'password'}
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
value={personalForm.password}
|
||||||
|
onChange={handlePersonalChange}
|
||||||
|
className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPersonalPassword(!showPersonalPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
>
|
||||||
|
{showPersonalPassword ? (
|
||||||
|
<EyeSlashIcon className="h-4 w-4 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<EyeIcon className="h-4 w-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{personalForm.password && renderPasswordStrength(personalForm.password)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
|
Passwort bestätigen *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={showPersonalPassword ? 'text' : 'password'}
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
value={personalForm.confirmPassword}
|
||||||
|
onChange={handlePersonalChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className={`w-full flex items-center justify-center py-3 px-4 rounded-lg text-white font-semibold transition-colors ${
|
||||||
|
loading
|
||||||
|
? 'bg-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-[#8D6B1D] hover:bg-[#7A5E1A] focus:ring-2 focus:ring-[#8D6B1D] focus:ring-offset-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
Registrierung läuft...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Jetzt registrieren'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleCompanySubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="companyName" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
|
Firmenname *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="companyName"
|
||||||
|
name="companyName"
|
||||||
|
value={companyForm.companyName}
|
||||||
|
onChange={handleCompanyChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="contactPersonName" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
|
Ansprechpartner *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="contactPersonName"
|
||||||
|
name="contactPersonName"
|
||||||
|
value={companyForm.contactPersonName}
|
||||||
|
onChange={handleCompanyChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="companyEmail" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
|
Firmen-E-Mail *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="companyEmail"
|
||||||
|
name="companyEmail"
|
||||||
|
value={companyForm.companyEmail}
|
||||||
|
onChange={handleCompanyChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmCompanyEmail" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
|
E-Mail bestätigen *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="confirmCompanyEmail"
|
||||||
|
name="confirmCompanyEmail"
|
||||||
|
value={companyForm.confirmCompanyEmail}
|
||||||
|
onChange={handleCompanyChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="companyPhone" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
|
Firmen-Telefon
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="companyPhone"
|
||||||
|
name="companyPhone"
|
||||||
|
value={companyForm.companyPhone}
|
||||||
|
onChange={handleCompanyChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors"
|
||||||
|
placeholder="+49 123 456 7890"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="contactPersonPhone" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
|
Ansprechpartner-Telefon
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="contactPersonPhone"
|
||||||
|
name="contactPersonPhone"
|
||||||
|
value={companyForm.contactPersonPhone}
|
||||||
|
onChange={handleCompanyChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors"
|
||||||
|
placeholder="+49 123 456 7890"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
|
Passwort *
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showCompanyPassword ? 'text' : 'password'}
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
value={companyForm.password}
|
||||||
|
onChange={handleCompanyChange}
|
||||||
|
className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCompanyPassword(!showCompanyPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
>
|
||||||
|
{showCompanyPassword ? (
|
||||||
|
<EyeSlashIcon className="h-4 w-4 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<EyeIcon className="h-4 w-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{companyForm.password && renderPasswordStrength(companyForm.password)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-[#0F172A] mb-2">
|
||||||
|
Passwort bestätigen *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={showCompanyPassword ? 'text' : 'password'}
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
value={companyForm.confirmPassword}
|
||||||
|
onChange={handleCompanyChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent transition-colors"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className={`w-full flex items-center justify-center py-3 px-4 rounded-lg text-white font-semibold transition-colors ${
|
||||||
|
loading
|
||||||
|
? 'bg-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-[#8D6B1D] hover:bg-[#7A5E1A] focus:ring-2 focus:ring-[#8D6B1D] focus:ring-offset-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
Registrierung läuft...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Firma registrieren'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Link */}
|
||||||
|
<div className="mt-8 text-center">
|
||||||
|
<p className="text-[#4A4A4A]">
|
||||||
|
Bereits registriert?{' '}
|
||||||
|
<a
|
||||||
|
href="/login"
|
||||||
|
className="text-[#8D6B1D] hover:text-[#7A5E1A] font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Hier anmelden
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
90
src/app/register/components/SessionDetectedModal.tsx
Normal file
90
src/app/register/components/SessionDetectedModal.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Fragment } from 'react'
|
||||||
|
import { Dialog, Transition } from '@headlessui/react'
|
||||||
|
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
|
interface SessionDetectedModalProps {
|
||||||
|
open: boolean
|
||||||
|
onLogout: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SessionDetectedModal({
|
||||||
|
open,
|
||||||
|
onLogout,
|
||||||
|
onCancel
|
||||||
|
}: SessionDetectedModalProps) {
|
||||||
|
return (
|
||||||
|
<Transition.Root show={open} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-50" onClose={onCancel}>
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 transition-opacity" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
>
|
||||||
|
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
|
||||||
|
<div className="sm:flex sm:items-start">
|
||||||
|
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-orange-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||||
|
<ExclamationTriangleIcon
|
||||||
|
className="h-6 w-6 text-orange-600"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
|
||||||
|
<Dialog.Title
|
||||||
|
as="h3"
|
||||||
|
className="text-base font-semibold leading-6 text-[#0F172A]"
|
||||||
|
>
|
||||||
|
Aktive Sitzung erkannt
|
||||||
|
</Dialog.Title>
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-sm text-[#4A4A4A]">
|
||||||
|
Du bist bereits angemeldet. Um dich zu registrieren, musst du dich zuerst abmelden
|
||||||
|
oder du kannst zum Dashboard gehen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex w-full justify-center rounded-md bg-[#8D6B1D] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-[#7A5E1A] sm:ml-3 sm:w-auto transition-colors"
|
||||||
|
onClick={onLogout}
|
||||||
|
>
|
||||||
|
Abmelden und registrieren
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-[#4A4A4A] shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto transition-colors"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Zum Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
152
src/app/register/page.tsx
Normal file
152
src/app/register/page.tsx
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation'
|
||||||
|
import PageLayout from '../components/PageLayout'
|
||||||
|
import useAuthStore from '../store/authStore'
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const refToken = searchParams.get('ref')
|
||||||
|
const [registered, setRegistered] = useState(false)
|
||||||
|
const [mode, setMode] = useState<'personal' | 'company'>('personal')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// Auth state
|
||||||
|
const user = useAuthStore(state => state.user)
|
||||||
|
const logout = useAuthStore(state => state.logout)
|
||||||
|
|
||||||
|
// Session management
|
||||||
|
const [showSessionModal, setShowSessionModal] = useState(false)
|
||||||
|
const [sessionCleared, setSessionCleared] = useState(false)
|
||||||
|
|
||||||
|
// Redirect to login on successful registration
|
||||||
|
useEffect(() => {
|
||||||
|
if (registered) {
|
||||||
|
// Show success toast would go here
|
||||||
|
setTimeout(() => router.push('/login'), 1200)
|
||||||
|
}
|
||||||
|
}, [registered, router])
|
||||||
|
|
||||||
|
// Check for existing session
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && !sessionCleared) {
|
||||||
|
setShowSessionModal(true)
|
||||||
|
}
|
||||||
|
}, [user, sessionCleared])
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout()
|
||||||
|
setSessionCleared(true)
|
||||||
|
setShowSessionModal(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setShowSessionModal(false)
|
||||||
|
router.push('/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block registration if session detected and not cleared
|
||||||
|
if (showSessionModal) {
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
|
||||||
|
<h3 className="text-lg font-semibold text-[#0F172A] mb-4">
|
||||||
|
Aktive Sitzung erkannt
|
||||||
|
</h3>
|
||||||
|
<p className="text-[#4A4A4A] mb-6">
|
||||||
|
Du bist bereits angemeldet. Um dich zu registrieren, musst du dich zuerst abmelden.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex-1 bg-[#8D6B1D] text-white px-4 py-2 rounded-lg hover:bg-[#7A5E1A] transition-colors"
|
||||||
|
>
|
||||||
|
Abmelden und registrieren
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="flex-1 bg-gray-100 text-[#4A4A4A] px-4 py-2 rounded-lg hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
Zum Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent form rendering until session is cleared
|
||||||
|
if (!sessionCleared && user) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<div className="min-h-screen w-full flex flex-col px-4 py-8">
|
||||||
|
<div className="flex-1 flex items-start justify-center w-full pt-8">
|
||||||
|
<div className="w-full max-w-4xl mx-auto bg-white rounded-2xl shadow-2xl px-6 py-8 sm:px-12 sm:py-10">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-extrabold text-[#0F172A] mb-4">
|
||||||
|
Registrierung für Profit Planet
|
||||||
|
</h2>
|
||||||
|
{refToken && (
|
||||||
|
<p className="text-base sm:text-sm text-[#8D6B1D] font-medium mb-8">
|
||||||
|
Du wurdest eingeladen! Referral-Token: {refToken}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mode Toggle */}
|
||||||
|
<div className="flex justify-center mb-8">
|
||||||
|
<div className="bg-gray-100 p-1 rounded-lg">
|
||||||
|
<button
|
||||||
|
className={`px-6 py-2 rounded-md font-semibold text-sm transition-all duration-200 ${
|
||||||
|
mode === 'personal'
|
||||||
|
? 'bg-[#8D6B1D] text-white shadow-sm'
|
||||||
|
: 'bg-transparent text-[#4A4A4A] hover:text-[#8D6B1D]'
|
||||||
|
}`}
|
||||||
|
onClick={() => setMode('personal')}
|
||||||
|
>
|
||||||
|
Privatperson
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`px-6 py-2 rounded-md font-semibold text-sm transition-all duration-200 ${
|
||||||
|
mode === 'company'
|
||||||
|
? 'bg-[#8D6B1D] text-white shadow-sm'
|
||||||
|
: 'bg-transparent text-[#4A4A4A] hover:text-[#8D6B1D]'
|
||||||
|
}`}
|
||||||
|
onClick={() => setMode('company')}
|
||||||
|
>
|
||||||
|
Unternehmen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-[#4A4A4A] mb-4">
|
||||||
|
Registrierungsmodus: <strong>{mode === 'personal' ? 'Privatperson' : 'Unternehmen'}</strong>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setRegistered(true)}
|
||||||
|
className="bg-[#8D6B1D] text-white px-6 py-3 rounded-lg hover:bg-[#7A5E1A] transition-colors font-semibold"
|
||||||
|
>
|
||||||
|
Test: Registrierung erfolgreich simulieren
|
||||||
|
</button>
|
||||||
|
<div className="mt-8">
|
||||||
|
<p className="text-[#4A4A4A]">
|
||||||
|
Bereits registriert?{' '}
|
||||||
|
<a href="/login" className="text-[#8D6B1D] hover:text-[#7A5E1A] font-medium transition-colors">
|
||||||
|
Hier anmelden
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
313
src/app/shop/page.tsx
Normal file
313
src/app/shop/page.tsx
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { StarIcon } from '@heroicons/react/20/solid'
|
||||||
|
import { HeartIcon, ShoppingCartIcon } from '@heroicons/react/24/outline'
|
||||||
|
import { HeartIcon as HeartIconSolid } from '@heroicons/react/24/solid'
|
||||||
|
import PageLayout from '../components/PageLayout'
|
||||||
|
|
||||||
|
// Mock-Produktdaten im Tailwind UI Plus Format
|
||||||
|
const products = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Premium Bio-Kaffee Starter Set',
|
||||||
|
price: '€24.99',
|
||||||
|
rating: 5,
|
||||||
|
reviewCount: 142,
|
||||||
|
imageSrc: 'https://images.unsplash.com/photo-1559056199-641a0ac8b55e?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80',
|
||||||
|
imageAlt: 'Premium Bio-Kaffee Set mit Bohnen und Filter',
|
||||||
|
href: '#',
|
||||||
|
category: 'Getränke',
|
||||||
|
inStock: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Nachhaltiger Laptop-Ständer',
|
||||||
|
price: '€89.99',
|
||||||
|
rating: 5,
|
||||||
|
reviewCount: 87,
|
||||||
|
imageSrc: 'https://images.unsplash.com/photo-1527864550417-7fd91fc51a46?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80',
|
||||||
|
imageAlt: 'Ergonomischer Laptop-Ständer aus Bambus',
|
||||||
|
href: '#',
|
||||||
|
category: 'Technik',
|
||||||
|
inStock: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Öko-Sportbekleidung Set',
|
||||||
|
price: '€149.99',
|
||||||
|
rating: 5,
|
||||||
|
reviewCount: 203,
|
||||||
|
imageSrc: 'https://images.unsplash.com/photo-1506629905607-b5f9a71351e8?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80',
|
||||||
|
imageAlt: 'Nachhaltige Sportkleidung aus recycelten Materialien',
|
||||||
|
href: '#',
|
||||||
|
category: 'Kleidung',
|
||||||
|
inStock: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: 'Smart Home Energie-Monitor',
|
||||||
|
price: '€199.99',
|
||||||
|
rating: 4,
|
||||||
|
reviewCount: 156,
|
||||||
|
imageSrc: 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80',
|
||||||
|
imageAlt: 'Smart Home Gerät zur Energieüberwachung',
|
||||||
|
href: '#',
|
||||||
|
category: 'Technik',
|
||||||
|
inStock: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: 'Bio-Hautpflege Starter-Set',
|
||||||
|
price: '€79.99',
|
||||||
|
rating: 4,
|
||||||
|
reviewCount: 92,
|
||||||
|
imageSrc: 'https://images.unsplash.com/photo-1556228578-8c89e6adf883?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80',
|
||||||
|
imageAlt: 'Natürliche Hautpflege Produkte ohne Chemikalien',
|
||||||
|
href: '#',
|
||||||
|
category: 'Beauty',
|
||||||
|
inStock: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
name: 'Solarbetriebene Powerbank',
|
||||||
|
price: '€129.99',
|
||||||
|
rating: 5,
|
||||||
|
reviewCount: 78,
|
||||||
|
imageSrc: 'https://images.unsplash.com/photo-1609091839311-d5365f9ff1c5?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80',
|
||||||
|
imageAlt: 'Portable Solarenergie Powerbank',
|
||||||
|
href: '#',
|
||||||
|
category: 'Technik',
|
||||||
|
inStock: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: 'Nachhaltige Trinkflasche',
|
||||||
|
price: '€25.99',
|
||||||
|
rating: 4,
|
||||||
|
reviewCount: 64,
|
||||||
|
imageSrc: 'https://images.unsplash.com/photo-1523362628745-0c100150b504?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80',
|
||||||
|
imageAlt: 'Wiederverwendbare Edelstahl Trinkflasche',
|
||||||
|
href: '#',
|
||||||
|
category: 'Lifestyle',
|
||||||
|
inStock: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
name: 'Öko-Notizbuch Set',
|
||||||
|
price: '€19.99',
|
||||||
|
rating: 5,
|
||||||
|
reviewCount: 41,
|
||||||
|
imageSrc: 'https://images.unsplash.com/photo-1544716278-ca5e3f4abd8c?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80',
|
||||||
|
imageAlt: 'Recyceltes Papier Notizbuch Set',
|
||||||
|
href: '#',
|
||||||
|
category: 'Büro',
|
||||||
|
inStock: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
name: 'Bambus Handy-Halterung',
|
||||||
|
price: '€32.99',
|
||||||
|
rating: 4,
|
||||||
|
reviewCount: 24,
|
||||||
|
imageSrc: 'https://images.unsplash.com/photo-1572635196237-14b3f281503f?ixlib=rb-4.0.3&auto=format&fit=crop&w=800&q=80',
|
||||||
|
imageAlt: 'Nachhaltige Bambus Handy-Halterung',
|
||||||
|
href: '#',
|
||||||
|
category: 'Technik',
|
||||||
|
inStock: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function classNames(...classes: (string | undefined | null | boolean)[]): string {
|
||||||
|
return classes.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShopPage() {
|
||||||
|
const [favorites, setFavorites] = useState<number[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
|
// Load favorites from localStorage on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
const savedFavorites = localStorage.getItem('shop-favorites')
|
||||||
|
if (savedFavorites) {
|
||||||
|
try {
|
||||||
|
setFavorites(JSON.parse(savedFavorites))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing favorites from localStorage:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Save favorites to localStorage whenever favorites change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading) {
|
||||||
|
localStorage.setItem('shop-favorites', JSON.stringify(favorites))
|
||||||
|
}
|
||||||
|
}, [favorites, isLoading])
|
||||||
|
|
||||||
|
const toggleFavorite = (productId: number) => {
|
||||||
|
setFavorites(prev => {
|
||||||
|
const newFavorites = prev.includes(productId)
|
||||||
|
? prev.filter(id => id !== productId)
|
||||||
|
: [...prev, productId]
|
||||||
|
|
||||||
|
// Show feedback to user
|
||||||
|
const product = products.find(p => p.id === productId)
|
||||||
|
if (product) {
|
||||||
|
if (newFavorites.includes(productId)) {
|
||||||
|
console.log(`❤️ ${product.name} zu Favoriten hinzugefügt`)
|
||||||
|
} else {
|
||||||
|
console.log(`💔 ${product.name} aus Favoriten entfernt`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newFavorites
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addToCart = (productId: number) => {
|
||||||
|
const product = products.find(p => p.id === productId)
|
||||||
|
if (product) {
|
||||||
|
console.log(`🛒 ${product.name} zum Warenkorb hinzugefügt`)
|
||||||
|
// Hier würde die echte Add-to-Cart Logik implementiert werden
|
||||||
|
// z.B. API-Call oder Zustand-Update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
||||||
|
<p className="text-[#4A4A4A]">Shop wird geladen...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout>
|
||||||
|
<div className="bg-white">
|
||||||
|
{/* Header Section */}
|
||||||
|
<div className="bg-[#0F172A] text-white py-16">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-4xl font-bold mb-4">Profit Planet Shop</h1>
|
||||||
|
<p className="text-xl text-gray-300 max-w-2xl mx-auto">
|
||||||
|
Entdecke nachhaltige und innovative Produkte, die sowohl deinem Geldbeutel als auch dem Planeten helfen
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Products Section - Tailwind UI Plus "Product Grid" */}
|
||||||
|
<div className="mx-auto max-w-2xl px-4 py-16 sm:px-6 sm:py-24 lg:max-w-7xl lg:px-8">
|
||||||
|
<h2 className="sr-only">Products</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8">
|
||||||
|
{products.map((product) => (
|
||||||
|
<a key={product.id} href={product.href} className="group">
|
||||||
|
<div className="relative">
|
||||||
|
{/* Product Image */}
|
||||||
|
<div className="aspect-h-1 aspect-w-1 w-full overflow-hidden rounded-lg bg-gray-200 xl:aspect-h-8 xl:aspect-w-7">
|
||||||
|
<img
|
||||||
|
alt={product.imageAlt}
|
||||||
|
src={product.imageSrc}
|
||||||
|
className="h-full w-full object-cover object-center group-hover:opacity-75"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Favorite Button - Now with better positioning */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
toggleFavorite(product.id)
|
||||||
|
}}
|
||||||
|
className={classNames(
|
||||||
|
'absolute top-3 right-3 rounded-full bg-white p-2 text-gray-400 shadow-sm hover:bg-gray-50 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[#8D6B1D]',
|
||||||
|
'opacity-0 transition-opacity group-hover:opacity-100',
|
||||||
|
favorites.includes(product.id) ? 'opacity-100 text-red-500 hover:text-red-600' : ''
|
||||||
|
)}
|
||||||
|
title={favorites.includes(product.id) ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen'}
|
||||||
|
>
|
||||||
|
{favorites.includes(product.id) ? (
|
||||||
|
<HeartIconSolid className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<HeartIcon className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Category Badge */}
|
||||||
|
<div className="absolute top-3 left-3">
|
||||||
|
<span className="inline-flex items-center rounded-full bg-[#8D6B1D] px-2 py-1 text-xs font-medium text-white">
|
||||||
|
{product.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Out of Stock Overlay */}
|
||||||
|
{!product.inStock && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-black bg-opacity-50">
|
||||||
|
<span className="rounded-md bg-white px-3 py-1 text-sm font-semibold text-gray-900">
|
||||||
|
Ausverkauft
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Info */}
|
||||||
|
<div className="mt-4 flex justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm text-gray-700">
|
||||||
|
{product.name}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Rating */}
|
||||||
|
<div className="mt-1 flex items-center">
|
||||||
|
<div className="flex items-center">
|
||||||
|
{[0, 1, 2, 3, 4].map((rating) => (
|
||||||
|
<StarIcon
|
||||||
|
key={rating}
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classNames(
|
||||||
|
product.rating > rating ? 'text-yellow-400' : 'text-gray-300',
|
||||||
|
'h-4 w-4 flex-shrink-0'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="ml-1 text-sm text-gray-500">({product.reviewCount})</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-medium text-gray-900">{product.price}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add to Cart Button */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
addToCart(product.id)
|
||||||
|
}}
|
||||||
|
disabled={!product.inStock}
|
||||||
|
className={classNames(
|
||||||
|
'mt-4 flex w-full items-center justify-center rounded-md border border-transparent px-8 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:ring-offset-2',
|
||||||
|
product.inStock
|
||||||
|
? 'bg-[#8D6B1D] text-white hover:bg-[#7A5E1A]'
|
||||||
|
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ShoppingCartIcon className="mr-2 h-4 w-4" />
|
||||||
|
{product.inStock ? 'In den Warenkorb' : 'Ausverkauft'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
206
src/app/store/authStore.ts
Normal file
206
src/app/store/authStore.ts
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { log } from "../utils/logger";
|
||||||
|
|
||||||
|
// Helper to decode JWT and get expiry
|
||||||
|
function getTokenExpiry(token: string | null): Date | null {
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
const [, payload] = token.split(".");
|
||||||
|
const { exp } = JSON.parse(atob(payload));
|
||||||
|
return exp ? new Date(exp * 1000) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for sessionStorage with SSR safety
|
||||||
|
const getStoredUser = () => {
|
||||||
|
if (typeof window === 'undefined') return null; // SSR check
|
||||||
|
try {
|
||||||
|
const userData = sessionStorage.getItem('user');
|
||||||
|
const parsed = userData ? JSON.parse(userData) : null;
|
||||||
|
log("👤 Retrieved user from sessionStorage:", parsed ? `${parsed.email || parsed.companyName}` : null);
|
||||||
|
return parsed;
|
||||||
|
} catch (error) {
|
||||||
|
log("❌ Error retrieving user from sessionStorage:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
email?: string;
|
||||||
|
companyName?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthStore {
|
||||||
|
accessToken: string | null;
|
||||||
|
user: User | null;
|
||||||
|
isAuthReady: boolean;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
refreshPromise: Promise<boolean> | null;
|
||||||
|
setAuthReady: (ready: boolean) => void;
|
||||||
|
setAccessToken: (token: string | null) => void;
|
||||||
|
setUser: (userData: User | null) => void;
|
||||||
|
clearAuth: () => void;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
refreshAuthToken: () => Promise<boolean | null>;
|
||||||
|
getAuthState: () => AuthStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useAuthStore = create<AuthStore>((set, get) => ({
|
||||||
|
// Initialize with SSR-safe defaults
|
||||||
|
accessToken: null,
|
||||||
|
user: typeof window !== 'undefined' ? getStoredUser() : null,
|
||||||
|
isAuthReady: false,
|
||||||
|
isRefreshing: false,
|
||||||
|
refreshPromise: null,
|
||||||
|
|
||||||
|
setAuthReady: (ready) => {
|
||||||
|
log("🔔 Zustand: setAuthReady ->", ready);
|
||||||
|
set({ isAuthReady: !!ready });
|
||||||
|
},
|
||||||
|
|
||||||
|
setAccessToken: (token) => {
|
||||||
|
log("🔑 Zustand: Setting access token in memory:", token ? `${token.substring(0, 20)}...` : null);
|
||||||
|
if (token) {
|
||||||
|
const expiry = getTokenExpiry(token);
|
||||||
|
log("⏳ Zustand: Token expiry:", expiry ? expiry.toLocaleString() : "Unknown");
|
||||||
|
} else {
|
||||||
|
log("🗑️ Zustand: Clearing in-memory access token");
|
||||||
|
}
|
||||||
|
set({ accessToken: token });
|
||||||
|
},
|
||||||
|
|
||||||
|
setUser: (userData) => {
|
||||||
|
log("👤 Zustand: Setting user data:", userData ? `${userData.email || userData.companyName}` : null);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
if (userData) {
|
||||||
|
sessionStorage.setItem('user', JSON.stringify(userData));
|
||||||
|
log("✅ User data stored in sessionStorage successfully");
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem('user');
|
||||||
|
log("🗑️ User data removed from sessionStorage");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log("❌ Error storing user in sessionStorage:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set({ user: userData });
|
||||||
|
},
|
||||||
|
|
||||||
|
clearAuth: () => {
|
||||||
|
log("🧹 Zustand: Clearing all auth data from memory and removing persisted user");
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem('user');
|
||||||
|
log("✅ User cleared from sessionStorage");
|
||||||
|
sessionStorage.removeItem('accessToken');
|
||||||
|
log("✅ accessToken cleared from sessionStorage");
|
||||||
|
} catch (error) {
|
||||||
|
log("❌ Error clearing user/accessToken from sessionStorage:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set({ accessToken: null, user: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
log("🚪 Zustand: Logging out — revoking refresh token on server");
|
||||||
|
try {
|
||||||
|
const logoutUrl = `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/logout`;
|
||||||
|
log("🌐 Zustand: Calling logout endpoint:", logoutUrl);
|
||||||
|
const res = await fetch(logoutUrl, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
log("📡 Logout response status:", res.status);
|
||||||
|
try {
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
log("📦 Logout response body:", body);
|
||||||
|
} catch {}
|
||||||
|
// Attempt to clear refreshToken cookie client-side (will only work if not httpOnly)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
document.cookie = "refreshToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; SameSite=Strict";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log("❌ Error calling logout endpoint:", error);
|
||||||
|
} finally {
|
||||||
|
get().clearAuth();
|
||||||
|
get().setAuthReady(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshAuthToken: async () => {
|
||||||
|
// If there's already a refresh in flight, return that promise
|
||||||
|
if (get().refreshPromise) {
|
||||||
|
log("🔁 Zustand: refreshAuthToken - returning existing refresh promise");
|
||||||
|
return get().refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHORT-CIRCUIT: if we already have a valid accessToken that's not about to expire, skip refresh
|
||||||
|
const currentToken = get().accessToken;
|
||||||
|
if (currentToken) {
|
||||||
|
const expiry = getTokenExpiry(currentToken);
|
||||||
|
if (expiry && expiry.getTime() - Date.now() > 60 * 1000) { // more than 60s left
|
||||||
|
log("⏸️ Zustand: accessToken present and valid, skipping refresh");
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log("🔄 Zustand: refreshAuthToken - starting new refresh");
|
||||||
|
// create promise so concurrent callers can await it
|
||||||
|
const p = (async () => {
|
||||||
|
set({ isRefreshing: true });
|
||||||
|
try {
|
||||||
|
const refreshUrl = `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/refresh`;
|
||||||
|
log("🌐 Zustand: Calling refresh endpoint:", refreshUrl);
|
||||||
|
|
||||||
|
const res = await fetch(refreshUrl, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include"
|
||||||
|
});
|
||||||
|
|
||||||
|
log("📡 Zustand: Refresh response status:", res.status);
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
log("📦 Zustand: Refresh response body:", body);
|
||||||
|
|
||||||
|
if (res.ok && body && body.accessToken) {
|
||||||
|
log("✅ Zustand: Refresh succeeded, setting in-memory token and user");
|
||||||
|
get().setAccessToken(body.accessToken);
|
||||||
|
if (body.user) get().setUser(body.user);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
log("❌ Zustand: Refresh failed (no accessToken or non-ok). Clearing auth state");
|
||||||
|
get().clearAuth();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log("❌ Zustand: Refresh error:", error);
|
||||||
|
get().clearAuth();
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
set({ isRefreshing: false, refreshPromise: null });
|
||||||
|
log("🔔 Zustand: refreshAuthToken - finished (flags cleared)");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
set({ refreshPromise: p });
|
||||||
|
return p;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAuthState: () => {
|
||||||
|
const state = get();
|
||||||
|
log("📊 Current auth state:", {
|
||||||
|
hasToken: !!state.accessToken,
|
||||||
|
tokenPrefix: state.accessToken ? `${state.accessToken.substring(0, 20)}...` : null,
|
||||||
|
user: state.user ? `${state.user.email || state.user.companyName}` : null
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useAuthStore;
|
||||||
115
src/app/utils/authFetch.ts
Normal file
115
src/app/utils/authFetch.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import useAuthStore from "../store/authStore";
|
||||||
|
import { log } from "./logger";
|
||||||
|
|
||||||
|
// Helper to decode JWT and get expiry
|
||||||
|
function getTokenExpiry(token: string | null): Date | null {
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
const [, payload] = token.split(".");
|
||||||
|
const { exp } = JSON.parse(atob(payload));
|
||||||
|
return exp ? new Date(exp * 1000) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get the current accessToken from Zustand
|
||||||
|
function getAccessToken(): string | null {
|
||||||
|
const token = useAuthStore.getState().accessToken;
|
||||||
|
log("🔑 authFetch: Getting token from Zustand:", token ? `${token.substring(0, 20)}...` : null);
|
||||||
|
if (token) {
|
||||||
|
const expiry = getTokenExpiry(token);
|
||||||
|
log("⏳ authFetch: Token expiry:", expiry ? expiry.toLocaleString() : "Unknown");
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to update the accessToken in Zustand
|
||||||
|
function setAccessToken(token: string | null): void {
|
||||||
|
log("🔑 authFetch: Updating token in Zustand (memory only):", token ? `${token.substring(0, 20)}...` : null);
|
||||||
|
if (token) {
|
||||||
|
const expiry = getTokenExpiry(token);
|
||||||
|
log("⏳ authFetch: New token expiry:", expiry ? expiry.toLocaleString() : "Unknown");
|
||||||
|
}
|
||||||
|
useAuthStore.getState().setAccessToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to clear auth state
|
||||||
|
function clearAuth(): void {
|
||||||
|
log("🧹 authFetch: Clearing auth state client-side");
|
||||||
|
useAuthStore.getState().clearAuth();
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomRequestInit extends RequestInit {
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main authFetch function
|
||||||
|
export async function authFetch(input: RequestInfo | URL, init: CustomRequestInit = {}): Promise<Response> {
|
||||||
|
const accessToken = getAccessToken();
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url;
|
||||||
|
|
||||||
|
log("🌐 authFetch: Making API call to:", url);
|
||||||
|
log("🔑 authFetch: Using token:", accessToken ? `${accessToken.substring(0, 20)}...` : "No token");
|
||||||
|
|
||||||
|
// Add Authorization header if accessToken exists
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
...(init.headers || {}),
|
||||||
|
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Always send credentials so refresh cookie is included when server-side refresh is needed
|
||||||
|
const fetchWithAuth = async (hdrs: Record<string, string>): Promise<Response> => {
|
||||||
|
log("📡 authFetch: Sending request", { url, headers: Object.keys(hdrs), credentials: "include" });
|
||||||
|
return fetch(url, { ...init, headers: hdrs, credentials: "include" });
|
||||||
|
};
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetchWithAuth(headers);
|
||||||
|
} catch (err) {
|
||||||
|
log("❌ authFetch: Network/error calling API:", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
log("📡 authFetch: Response status:", res.status);
|
||||||
|
|
||||||
|
// If unauthorized, try to refresh token and retry once
|
||||||
|
if (res.status === 401) {
|
||||||
|
log("🔄 authFetch: 401 Unauthorized received. Attempting store.refreshAuthToken()...");
|
||||||
|
try {
|
||||||
|
// call centralized, deduped refresh in store
|
||||||
|
const refreshOk = await useAuthStore.getState().refreshAuthToken();
|
||||||
|
log("🔄 authFetch: store.refreshAuthToken() result:", refreshOk);
|
||||||
|
|
||||||
|
if (refreshOk) {
|
||||||
|
// get new token from memory (store already set it)
|
||||||
|
const newToken = getAccessToken();
|
||||||
|
log("🔁 authFetch: Retrieved new token from store after refresh:", newToken ? `${newToken.substring(0,20)}...` : null);
|
||||||
|
|
||||||
|
const retryHeaders = {
|
||||||
|
...headers,
|
||||||
|
...(newToken ? { Authorization: `Bearer ${newToken}` } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
log("🔄 authFetch: Retrying original request with refreshed token (if available)");
|
||||||
|
res = await fetch(url, { ...init, headers: retryHeaders, credentials: "include" });
|
||||||
|
log("📡 authFetch: Retry response status:", res.status);
|
||||||
|
} else {
|
||||||
|
log("❌ authFetch: Refresh failed. Calling logout to revoke server cookie and clear client state");
|
||||||
|
await useAuthStore.getState().logout().catch((e) => {
|
||||||
|
log("❌ authFetch: logout error:", e);
|
||||||
|
useAuthStore.getState().clearAuth();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log("❌ authFetch: Error while refreshing token:", err);
|
||||||
|
await useAuthStore.getState().logout().catch((e) => {
|
||||||
|
log("❌ authFetch: logout error after refresh exception:", e);
|
||||||
|
useAuthStore.getState().clearAuth();
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
4
src/app/utils/cn.ts
Normal file
4
src/app/utils/cn.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// Simple className utility without external dependencies
|
||||||
|
export function cn(...classes: (string | undefined | null | boolean)[]): string {
|
||||||
|
return classes.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
41
src/app/utils/logger.ts
Normal file
41
src/app/utils/logger.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
// Simple logger with different log levels
|
||||||
|
const LOG_LEVELS = {
|
||||||
|
DEBUG: 0,
|
||||||
|
INFO: 1,
|
||||||
|
WARN: 2,
|
||||||
|
ERROR: 3
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type LogLevel = keyof typeof LOG_LEVELS;
|
||||||
|
|
||||||
|
// Set current log level based on environment
|
||||||
|
const currentLogLevel = process.env.NODE_ENV === 'production' ? LOG_LEVELS.WARN : LOG_LEVELS.DEBUG;
|
||||||
|
|
||||||
|
function formatMessage(level: LogLevel, message: string, ...args: any[]): string {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
return `[${timestamp}] [${level}] ${message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function log(message: string, ...args: any[]): void {
|
||||||
|
if (LOG_LEVELS.DEBUG >= currentLogLevel) {
|
||||||
|
console.log(formatMessage('DEBUG', message), ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function info(message: string, ...args: any[]): void {
|
||||||
|
if (LOG_LEVELS.INFO >= currentLogLevel) {
|
||||||
|
console.info(formatMessage('INFO', message), ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function warn(message: string, ...args: any[]): void {
|
||||||
|
if (LOG_LEVELS.WARN >= currentLogLevel) {
|
||||||
|
console.warn(formatMessage('WARN', message), ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function error(message: string, ...args: any[]): void {
|
||||||
|
if (LOG_LEVELS.ERROR >= currentLogLevel) {
|
||||||
|
console.error(formatMessage('ERROR', message), ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
tailwind.config.js
Normal file
43
tailwind.config.js
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./node_modules/@tailwindui/react/**/*.js",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
accent: 'var(--color-brand-accent)',
|
||||||
|
header: 'var(--color-brand-header)',
|
||||||
|
text: 'var(--color-brand-text)',
|
||||||
|
background: 'var(--color-brand-background)',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
blob: "blob 7s infinite",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
blob: {
|
||||||
|
"0%": {
|
||||||
|
transform: "translate(0px, 0px) scale(1)",
|
||||||
|
},
|
||||||
|
"33%": {
|
||||||
|
transform: "translate(30px, -50px) scale(1.1)",
|
||||||
|
},
|
||||||
|
"66%": {
|
||||||
|
transform: "translate(-20px, 20px) scale(0.9)",
|
||||||
|
},
|
||||||
|
"100%": {
|
||||||
|
transform: "translate(0px, 0px) scale(1)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
require('@tailwindcss/forms'),
|
||||||
|
require('@tailwindcss/typography'),
|
||||||
|
],
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user