Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a98e459ee | |||
| 4b251b8e50 | |||
| 314c65fbfd | |||
| a7a8eba464 | |||
| e5a896e9e4 | |||
| d41261e1cc | |||
| 68b168061d | |||
| 4c0a5a78fe | |||
| 0ddac07cbe | |||
| a9b28fe88f | |||
| bdadc5fa20 | |||
| 7e395a9bf6 | |||
| e6239e490f | |||
| f0dfa51511 | |||
| 64214bb6f3 | |||
| eacd01efe1 | |||
| f68ab32eea | |||
| f0fdef56e3 | |||
| 9803d7c8d4 | |||
| cbcdc2a401 | |||
| 436fc50bda | |||
| 17cc11c0a7 | |||
| 257bec55b5 | |||
| 4e1a47682d | |||
| 303c4e0f55 | |||
| 5d360e28c5 | |||
| 2c41a48def | |||
| 3d92d71819 | |||
| 49f7244109 | |||
| ec1b3001a4 | |||
| c99341edd9 | |||
| aff8999ac8 | |||
| a5d4ec5e96 | |||
| 53b4817e80 | |||
| f4141e857d | |||
| 32c63b5f3c | |||
| 9467e5c4bd | |||
| 29996e7f70 | |||
| 61c3bc6aa1 | |||
| 544c44a6bd | |||
| 6ea10dae0b | |||
| 340a69ccf6 | |||
| 2e0e7f3be4 | |||
| 4d6b0d488a | |||
| 342adc2d8e | |||
| 643c859894 | |||
| 8d2bd20637 | |||
| 8b965ca5af | |||
| b276c8fa73 |
@@ -1,36 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const authHeader = request.headers.get("authorization");
|
|
||||||
|
|
||||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Token não fornecido" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = authHeader.substring(7);
|
|
||||||
|
|
||||||
// Validate token (in production, verify JWT signature)
|
|
||||||
if (token.length !== 64) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Token inválido" },
|
|
||||||
{ status: 401 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
valid: true,
|
|
||||||
message: "Sessão válida"},
|
|
||||||
{ status: 200 }
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ message: "Erro ao verificar sessão" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
|
||||||
import NavbarStyleCentered from '@/components/navbar/NavbarStyleCentered/NavbarStyleCentered';
|
|
||||||
import ContactSplit from '@/components/sections/contact/ContactSplit';
|
|
||||||
import FooterBase from '@/components/sections/footer/FooterBase';
|
|
||||||
import { Mail, Lock } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError("");
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Validate inputs
|
|
||||||
if (!email || !password) {
|
|
||||||
setError("Por favor, preencha todos os campos.");
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
||||||
setError("Por favor, insira um email válido.");
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.length < 6) {
|
|
||||||
setError("A senha deve ter pelo menos 6 caracteres.");
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call login API
|
|
||||||
const response = await fetch("/api/auth/login", {
|
|
||||||
method: "POST", headers: {
|
|
||||||
"Content-Type": "application/json"},
|
|
||||||
body: JSON.stringify({ email, password }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
setError(data.message || "Erro ao fazer login. Tente novamente.");
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store user session
|
|
||||||
localStorage.setItem("userSession", JSON.stringify({
|
|
||||||
token: data.token,
|
|
||||||
user: data.user,
|
|
||||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Redirect to dashboard
|
|
||||||
window.location.href = "/dashboard";
|
|
||||||
} catch (err) {
|
|
||||||
setError("Erro de conexão. Tente novamente mais tarde.");
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeProvider
|
|
||||||
defaultButtonVariant="elastic-effect"
|
|
||||||
defaultTextAnimation="entrance-slide"
|
|
||||||
borderRadius="pill"
|
|
||||||
contentWidth="smallMedium"
|
|
||||||
sizing="mediumSizeLargeTitles"
|
|
||||||
background="blurBottom"
|
|
||||||
cardStyle="gradient-bordered"
|
|
||||||
primaryButtonStyle="flat"
|
|
||||||
secondaryButtonStyle="glass"
|
|
||||||
headingFontWeight="extrabold"
|
|
||||||
>
|
|
||||||
<div id="nav" data-section="nav">
|
|
||||||
<NavbarStyleCentered
|
|
||||||
navItems={[
|
|
||||||
{ name: "Dashboard", id: "/dashboard" },
|
|
||||||
{ name: "Treino", id: "training" },
|
|
||||||
{ name: "Nutrição", id: "nutrition" },
|
|
||||||
{ name: "Comunidade", id: "community" },
|
|
||||||
{ name: "Perfil", id: "profile" }
|
|
||||||
]}
|
|
||||||
button={{ text: "Começar Agora", href: "contact" }}
|
|
||||||
brandName="FitFlow Pro"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="login" data-section="login" className="min-h-screen flex items-center justify-center py-20 px-4">
|
|
||||||
<div className="w-full max-w-md">
|
|
||||||
<div className="rounded-2xl border border-primary-cta/20 bg-card p-8 shadow-lg">
|
|
||||||
<h1 className="text-3xl font-bold text-foreground mb-2">Bem-vindo de Volta</h1>
|
|
||||||
<p className="text-foreground/60 mb-8">Faça login em sua conta FitFlow Pro</p>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-6 p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-600 text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleLogin} className="space-y-5">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-foreground mb-2">
|
|
||||||
Email
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-foreground/40" />
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder="seu.email@exemplo.com"
|
|
||||||
className="w-full pl-10 pr-4 py-2 rounded-lg border border-foreground/10 bg-background focus:outline-none focus:border-primary-cta/50 text-foreground placeholder-foreground/40 transition"
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-foreground mb-2">
|
|
||||||
Senha
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-foreground/40" />
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="••••••••"
|
|
||||||
className="w-full pl-10 pr-4 py-2 rounded-lg border border-foreground/10 bg-background focus:outline-none focus:border-primary-cta/50 text-foreground placeholder-foreground/40 transition"
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full py-3 rounded-lg bg-primary-cta text-white font-medium hover:bg-primary-cta/90 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{loading ? "Entrando..." : "Entrar"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
|
||||||
<p className="text-foreground/60 text-sm">
|
|
||||||
Não tem uma conta?{" "}
|
|
||||||
<a href="/signup" className="text-primary-cta hover:underline font-medium">
|
|
||||||
Cadastre-se aqui
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
|
||||||
<a href="/" className="text-primary-cta hover:underline font-medium text-sm">
|
|
||||||
Voltar para Home
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="footer" data-section="footer">
|
|
||||||
<FooterBase
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
title: "Produto", items: [
|
|
||||||
{ label: "Dashboard", href: "dashboard" },
|
|
||||||
{ label: "Treino", href: "training" },
|
|
||||||
{ label: "Nutrição", href: "nutrition" },
|
|
||||||
{ label: "Cardio Hub", href: "cardio" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Comunidade", items: [
|
|
||||||
{ label: "Comunidade", href: "community" },
|
|
||||||
{ label: "Perfil", href: "profile" },
|
|
||||||
{ label: "Rankings", href: "rankings" },
|
|
||||||
{ label: "Blog", href: "blog" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Empresa", items: [
|
|
||||||
{ label: "Sobre", href: "about" },
|
|
||||||
{ label: "Contato", href: "contact" },
|
|
||||||
{ label: "Privacidade", href: "privacy" },
|
|
||||||
{ label: "Termos", href: "terms" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
logoText="FitFlow Pro"
|
|
||||||
copyrightText="© 2025 FitFlow Pro. Todos os direitos reservados."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ThemeProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
|
|
||||||
import NavbarStyleCentered from '@/components/navbar/NavbarStyleCentered/NavbarStyleCentered';
|
|
||||||
import FooterBase from '@/components/sections/footer/FooterBase';
|
|
||||||
import { ChevronRight, User, Users } from 'lucide-react';
|
|
||||||
|
|
||||||
interface UserProfile {
|
|
||||||
name: string;
|
|
||||||
gender: string;
|
|
||||||
height: string;
|
|
||||||
weight: string;
|
|
||||||
age: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type OnboardingStep = 'basic' | 'biometric' | 'complete';
|
|
||||||
|
|
||||||
export default function OnboardingPage() {
|
|
||||||
const [step, setStep] = useState<OnboardingStep>('basic');
|
|
||||||
const [profile, setProfile] = useState<UserProfile>({
|
|
||||||
name: '',
|
|
||||||
gender: '',
|
|
||||||
height: '',
|
|
||||||
weight: '',
|
|
||||||
age: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleBasicChange = (field: keyof Pick<UserProfile, 'name' | 'gender'>, value: string) => {
|
|
||||||
setProfile(prev => ({ ...prev, [field]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBiometricChange = (field: keyof Pick<UserProfile, 'height' | 'weight' | 'age'>, value: string) => {
|
|
||||||
setProfile(prev => ({ ...prev, [field]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBasicSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (profile.name && profile.gender) {
|
|
||||||
setStep('biometric');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBiometricSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (profile.height && profile.weight && profile.age) {
|
|
||||||
saveProfile();
|
|
||||||
setStep('complete');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveProfile = () => {
|
|
||||||
localStorage.setItem('userProfile', JSON.stringify(profile));
|
|
||||||
console.log('Profile saved:', profile);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
setProfile({ name: '', gender: '', height: '', weight: '', age: '' });
|
|
||||||
setStep('basic');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeProvider
|
|
||||||
defaultButtonVariant="elastic-effect"
|
|
||||||
defaultTextAnimation="entrance-slide"
|
|
||||||
borderRadius="pill"
|
|
||||||
contentWidth="smallMedium"
|
|
||||||
sizing="mediumSizeLargeTitles"
|
|
||||||
background="blurBottom"
|
|
||||||
cardStyle="gradient-bordered"
|
|
||||||
primaryButtonStyle="flat"
|
|
||||||
secondaryButtonStyle="glass"
|
|
||||||
headingFontWeight="extrabold"
|
|
||||||
>
|
|
||||||
<div id="nav" data-section="nav">
|
|
||||||
<NavbarStyleCentered
|
|
||||||
navItems={[
|
|
||||||
{ name: "Dashboard", id: "dashboard" },
|
|
||||||
{ name: "Treino", id: "training" },
|
|
||||||
{ name: "Nutrição", id: "nutrition" },
|
|
||||||
{ name: "Comunidade", id: "community" },
|
|
||||||
{ name: "Perfil", id: "onboarding" }
|
|
||||||
]}
|
|
||||||
button={{ text: "Começar Agora", href: "contact" }}
|
|
||||||
brandName="FitFlow Pro"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="onboarding-flow" data-section="onboarding-flow" className="min-h-screen flex items-center justify-center py-20">
|
|
||||||
<div className="w-full max-w-2xl px-6">
|
|
||||||
{/* Progress Indicator */}
|
|
||||||
<div className="mb-12">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className={`flex items-center gap-2 ${
|
|
||||||
step === 'basic' || step === 'biometric' || step === 'complete'
|
|
||||||
? 'text-primary-cta'
|
|
||||||
: 'text-gray-400'
|
|
||||||
}`}>
|
|
||||||
<div className="w-8 h-8 rounded-full bg-primary-cta flex items-center justify-center text-white text-sm font-bold">1</div>
|
|
||||||
<span className="font-semibold">Dados Pessoais</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 h-1 mx-4 bg-gray-300"></div>
|
|
||||||
<div className={`flex items-center gap-2 ${
|
|
||||||
step === 'biometric' || step === 'complete'
|
|
||||||
? 'text-primary-cta'
|
|
||||||
: 'text-gray-400'
|
|
||||||
}`}>
|
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
|
|
||||||
step === 'biometric' || step === 'complete'
|
|
||||||
? 'bg-primary-cta text-white'
|
|
||||||
: 'bg-gray-300 text-gray-600'
|
|
||||||
}`}>2</div>
|
|
||||||
<span className="font-semibold">Biometria</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step 1: Basic Information */}
|
|
||||||
{step === 'basic' && (
|
|
||||||
<div className="bg-card rounded-2xl p-8 shadow-lg">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-4xl font-extrabold mb-2">Vamos Começar!</h1>
|
|
||||||
<p className="text-gray-600">Primeiro, precisamos conhecer você melhor.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleBasicSubmit} className="space-y-6">
|
|
||||||
{/* Name Input */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-semibold mb-2">Nome Completo</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={profile.name}
|
|
||||||
onChange={(e) => handleBasicChange('name', e.target.value)}
|
|
||||||
placeholder="Digite seu nome"
|
|
||||||
className="w-full px-4 py-3 rounded-lg border-2 border-gray-200 focus:border-primary-cta focus:outline-none transition"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gender Selection */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-semibold mb-2">Gênero</label>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
{['Masculino', 'Feminino'].map((gender) => (
|
|
||||||
<button
|
|
||||||
key={gender}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleBasicChange('gender', gender)}
|
|
||||||
className={`py-3 px-4 rounded-lg font-semibold transition-all ${
|
|
||||||
profile.gender === gender
|
|
||||||
? 'bg-primary-cta text-white'
|
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{gender}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Submit Button */}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!profile.name || !profile.gender}
|
|
||||||
className="w-full bg-primary-cta text-white py-3 rounded-lg font-semibold hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
Próximo <ChevronRight size={18} />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 2: Biometric Information */}
|
|
||||||
{step === 'biometric' && (
|
|
||||||
<div className="bg-card rounded-2xl p-8 shadow-lg">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-4xl font-extrabold mb-2">Dados Biométricos</h1>
|
|
||||||
<p className="text-gray-600">Agora, alguns dados de biometria para personalizar seu treino.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleBiometricSubmit} className="space-y-6">
|
|
||||||
{/* Height Input */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-semibold mb-2">Altura (cm)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={profile.height}
|
|
||||||
onChange={(e) => handleBiometricChange('height', e.target.value)}
|
|
||||||
placeholder="Ex: 175"
|
|
||||||
className="w-full px-4 py-3 rounded-lg border-2 border-gray-200 focus:border-primary-cta focus:outline-none transition"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Weight Input */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-semibold mb-2">Peso (kg)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={profile.weight}
|
|
||||||
onChange={(e) => handleBiometricChange('weight', e.target.value)}
|
|
||||||
placeholder="Ex: 75"
|
|
||||||
step="0.1"
|
|
||||||
className="w-full px-4 py-3 rounded-lg border-2 border-gray-200 focus:border-primary-cta focus:outline-none transition"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Age Input */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-semibold mb-2">Idade</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={profile.age}
|
|
||||||
onChange={(e) => handleBiometricChange('age', e.target.value)}
|
|
||||||
placeholder="Ex: 28"
|
|
||||||
className="w-full px-4 py-3 rounded-lg border-2 border-gray-200 focus:border-primary-cta focus:outline-none transition"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Buttons */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setStep('basic')}
|
|
||||||
className="py-3 px-4 rounded-lg font-semibold border-2 border-gray-300 hover:bg-gray-100 transition"
|
|
||||||
>
|
|
||||||
Voltar
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!profile.height || !profile.weight || !profile.age}
|
|
||||||
className="bg-primary-cta text-white py-3 px-4 rounded-lg font-semibold hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
Concluir <ChevronRight size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 3: Completion */}
|
|
||||||
{step === 'complete' && (
|
|
||||||
<div className="bg-card rounded-2xl p-8 shadow-lg text-center">
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="w-16 h-16 bg-primary-cta rounded-full flex items-center justify-center mx-auto mb-6">
|
|
||||||
<User size={32} className="text-white" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-4xl font-extrabold mb-2">Perfil Criado com Sucesso!</h1>
|
|
||||||
<p className="text-gray-600 mb-6">Bem-vindo ao FitFlow Pro, {profile.name}!</p>
|
|
||||||
|
|
||||||
{/* Profile Summary */}
|
|
||||||
<div className="bg-gray-50 rounded-lg p-6 mb-8 text-left space-y-3">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="font-semibold text-gray-700">Nome:</span>
|
|
||||||
<span className="text-gray-600">{profile.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="font-semibold text-gray-700">Gênero:</span>
|
|
||||||
<span className="text-gray-600">{profile.gender}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="font-semibold text-gray-700">Altura:</span>
|
|
||||||
<span className="text-gray-600">{profile.height} cm</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="font-semibold text-gray-700">Peso:</span>
|
|
||||||
<span className="text-gray-600">{profile.weight} kg</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="font-semibold text-gray-700">Idade:</span>
|
|
||||||
<span className="text-gray-600">{profile.age} anos</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<button
|
|
||||||
onClick={handleReset}
|
|
||||||
className="py-3 px-4 rounded-lg font-semibold border-2 border-gray-300 hover:bg-gray-100 transition"
|
|
||||||
>
|
|
||||||
Editar Dados
|
|
||||||
</button>
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
className="bg-primary-cta text-white py-3 px-4 rounded-lg font-semibold hover:opacity-90 transition flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
Ir para Dashboard <ChevronRight size={18} />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="footer" data-section="footer">
|
|
||||||
<FooterBase
|
|
||||||
columns={[
|
|
||||||
{
|
|
||||||
title: "Produto", items: [
|
|
||||||
{ label: "Dashboard", href: "dashboard" },
|
|
||||||
{ label: "Treino", href: "training" },
|
|
||||||
{ label: "Nutrição", href: "nutrition" },
|
|
||||||
{ label: "Cardio Hub", href: "cardio" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Comunidade", items: [
|
|
||||||
{ label: "Comunidade", href: "community" },
|
|
||||||
{ label: "Perfil", href: "onboarding" },
|
|
||||||
{ label: "Rankings", href: "rankings" },
|
|
||||||
{ label: "Blog", href: "blog" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Empresa", items: [
|
|
||||||
{ label: "Sobre", href: "about" },
|
|
||||||
{ label: "Contato", href: "contact" },
|
|
||||||
{ label: "Privacidade", href: "privacy" },
|
|
||||||
{ label: "Termos", href: "terms" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
logoText="FitFlow Pro"
|
|
||||||
copyrightText="© 2025 FitFlow Pro. Todos os direitos reservados."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ThemeProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
fallback?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProtectedRoute({ children, fallback }: ProtectedRouteProps) {
|
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLoading && !isAuthenticated) {
|
|
||||||
router.push("/login");
|
|
||||||
}
|
|
||||||
}, [isLoading, isAuthenticated, router]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return fallback || <div className="flex items-center justify-center min-h-screen">Carregando...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@@ -1,25 +1,123 @@
|
|||||||
import { ReactNode } from 'react';
|
"use client";
|
||||||
import { useCardStack } from './CardStackContext';
|
|
||||||
|
|
||||||
export interface CardListProps {
|
import { memo, Children } from "react";
|
||||||
children: ReactNode;
|
import CardStackTextBox from "@/components/cardStack/CardStackTextBox";
|
||||||
className?: string;
|
import { useCardAnimation } from "@/components/cardStack/hooks/useCardAnimation";
|
||||||
|
import { cls } from "@/lib/utils";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import type { ButtonConfig, ButtonAnimationType, CardAnimationType, TitleSegment } from "@/components/cardStack/types";
|
||||||
|
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
|
||||||
|
|
||||||
|
interface CardListProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
animationType: CardAnimationType;
|
||||||
|
useUncappedRounding?: boolean;
|
||||||
|
title?: string;
|
||||||
|
titleSegments?: TitleSegment[];
|
||||||
|
description?: string;
|
||||||
|
tag?: string;
|
||||||
|
tagIcon?: LucideIcon;
|
||||||
|
tagAnimation?: ButtonAnimationType;
|
||||||
|
buttons?: ButtonConfig[];
|
||||||
|
buttonAnimation?: ButtonAnimationType;
|
||||||
|
textboxLayout: TextboxLayout;
|
||||||
|
useInvertedBackground?: InvertedBackground;
|
||||||
|
disableCardWrapper?: boolean;
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
containerClassName?: string;
|
||||||
|
cardClassName?: string;
|
||||||
|
textBoxClassName?: string;
|
||||||
|
titleClassName?: string;
|
||||||
|
titleImageWrapperClassName?: string;
|
||||||
|
titleImageClassName?: string;
|
||||||
|
descriptionClassName?: string;
|
||||||
|
tagClassName?: string;
|
||||||
|
buttonContainerClassName?: string;
|
||||||
|
buttonClassName?: string;
|
||||||
|
buttonTextClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardList({ children, className = '', ariaLabel = 'Card list' }: CardListProps) {
|
const CardList = ({
|
||||||
const { isVisible, getAnimationProps } = useCardStack();
|
children,
|
||||||
const animationProps = getAnimationProps();
|
animationType,
|
||||||
|
useUncappedRounding = false,
|
||||||
|
title,
|
||||||
|
titleSegments,
|
||||||
|
description,
|
||||||
|
tag,
|
||||||
|
tagIcon,
|
||||||
|
tagAnimation,
|
||||||
|
buttons,
|
||||||
|
buttonAnimation,
|
||||||
|
textboxLayout,
|
||||||
|
useInvertedBackground,
|
||||||
|
disableCardWrapper = false,
|
||||||
|
ariaLabel = "Card list",
|
||||||
|
className = "",
|
||||||
|
containerClassName = "",
|
||||||
|
cardClassName = "",
|
||||||
|
textBoxClassName = "",
|
||||||
|
titleClassName = "",
|
||||||
|
titleImageWrapperClassName = "",
|
||||||
|
titleImageClassName = "",
|
||||||
|
descriptionClassName = "",
|
||||||
|
tagClassName = "",
|
||||||
|
buttonContainerClassName = "",
|
||||||
|
buttonClassName = "",
|
||||||
|
buttonTextClassName = "",
|
||||||
|
}: CardListProps) => {
|
||||||
|
const childrenArray = Children.toArray(children);
|
||||||
|
const { itemRefs } = useCardAnimation({ animationType, itemCount: childrenArray.length, useIndividualTriggers: true });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<section
|
||||||
className={className}
|
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
data-is-visible={animationProps.isVisible}
|
className={cls(
|
||||||
|
"relative py-20 w-full",
|
||||||
|
useInvertedBackground && "bg-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
<div className={cls("w-content-width mx-auto flex flex-col gap-8", containerClassName)}>
|
||||||
</div>
|
<CardStackTextBox
|
||||||
);
|
title={title}
|
||||||
}
|
titleSegments={titleSegments}
|
||||||
|
description={description}
|
||||||
|
tag={tag}
|
||||||
|
tagIcon={tagIcon}
|
||||||
|
tagAnimation={tagAnimation}
|
||||||
|
buttons={buttons}
|
||||||
|
buttonAnimation={buttonAnimation}
|
||||||
|
textboxLayout={textboxLayout}
|
||||||
|
useInvertedBackground={useInvertedBackground}
|
||||||
|
textBoxClassName={textBoxClassName}
|
||||||
|
titleClassName={titleClassName}
|
||||||
|
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||||
|
titleImageClassName={titleImageClassName}
|
||||||
|
descriptionClassName={descriptionClassName}
|
||||||
|
tagClassName={tagClassName}
|
||||||
|
buttonContainerClassName={buttonContainerClassName}
|
||||||
|
buttonClassName={buttonClassName}
|
||||||
|
buttonTextClassName={buttonTextClassName}
|
||||||
|
/>
|
||||||
|
|
||||||
export default CardList;
|
<div className="flex flex-col gap-6">
|
||||||
|
{childrenArray.map((child, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
ref={(el) => { itemRefs.current[index] = el; }}
|
||||||
|
className={cls(!disableCardWrapper && "card", !disableCardWrapper && (useUncappedRounding ? "rounded-theme" : "rounded-theme-capped"), cardClassName)}
|
||||||
|
>
|
||||||
|
{child}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
CardList.displayName = "CardList";
|
||||||
|
|
||||||
|
export default memo(CardList);
|
||||||
|
|||||||
@@ -1,24 +1,229 @@
|
|||||||
import { ReactNode } from 'react';
|
"use client";
|
||||||
import { CardStackProvider, CardStackContextType } from './CardStackContext';
|
|
||||||
|
|
||||||
export interface CardStackProps {
|
import { memo, Children } from "react";
|
||||||
children: ReactNode;
|
import { CardStackProps } from "./types";
|
||||||
className?: string;
|
import GridLayout from "./layouts/grid/GridLayout";
|
||||||
ariaLabel?: string;
|
import AutoCarousel from "./layouts/carousels/AutoCarousel";
|
||||||
}
|
import ButtonCarousel from "./layouts/carousels/ButtonCarousel";
|
||||||
|
import TimelineBase from "./layouts/timelines/TimelineBase";
|
||||||
|
import { gridConfigs } from "./layouts/grid/gridConfigs";
|
||||||
|
|
||||||
export default function CardStack({ children, className = '', ariaLabel = 'Card stack' }: CardStackProps) {
|
const CardStack = ({
|
||||||
const contextValue: CardStackContextType = {
|
children,
|
||||||
isVisible: true,
|
mode = "buttons",
|
||||||
getAnimationProps: () => ({ isVisible: true }),
|
gridVariant = "uniform-all-items-equal",
|
||||||
itemRefs: {}
|
uniformGridCustomHeightClasses,
|
||||||
};
|
gridRowsClassName,
|
||||||
|
itemHeightClassesOverride,
|
||||||
|
animationType,
|
||||||
|
supports3DAnimation = false,
|
||||||
|
title,
|
||||||
|
titleSegments,
|
||||||
|
description,
|
||||||
|
tag,
|
||||||
|
tagIcon,
|
||||||
|
tagAnimation,
|
||||||
|
buttons,
|
||||||
|
buttonAnimation,
|
||||||
|
textboxLayout = "default",
|
||||||
|
useInvertedBackground,
|
||||||
|
carouselThreshold = 5,
|
||||||
|
bottomContent,
|
||||||
|
className = "",
|
||||||
|
containerClassName = "",
|
||||||
|
gridClassName = "",
|
||||||
|
carouselClassName = "",
|
||||||
|
carouselItemClassName = "",
|
||||||
|
controlsClassName = "",
|
||||||
|
textBoxClassName = "",
|
||||||
|
titleClassName = "",
|
||||||
|
titleImageWrapperClassName = "",
|
||||||
|
titleImageClassName = "",
|
||||||
|
descriptionClassName = "",
|
||||||
|
tagClassName = "",
|
||||||
|
buttonContainerClassName = "",
|
||||||
|
buttonClassName = "",
|
||||||
|
buttonTextClassName = "",
|
||||||
|
ariaLabel = "Card stack",
|
||||||
|
}: CardStackProps) => {
|
||||||
|
const childrenArray = Children.toArray(children);
|
||||||
|
const itemCount = childrenArray.length;
|
||||||
|
|
||||||
return (
|
// Check if the current grid config has gridRows defined
|
||||||
<CardStackProvider value={contextValue}>
|
const gridConfig = gridConfigs[gridVariant]?.[itemCount];
|
||||||
<div className={className} aria-label={ariaLabel}>
|
const hasFixedGridRows = gridConfig && 'gridRows' in gridConfig && gridConfig.gridRows;
|
||||||
{children}
|
|
||||||
</div>
|
// If grid has fixed row heights and we have uniformGridCustomHeightClasses,
|
||||||
</CardStackProvider>
|
// we need to use min-h-0 on md+ to prevent conflicts
|
||||||
);
|
let adjustedHeightClasses = uniformGridCustomHeightClasses;
|
||||||
}
|
if (hasFixedGridRows && uniformGridCustomHeightClasses) {
|
||||||
|
// Extract the mobile min-height and add md:min-h-0
|
||||||
|
const mobileMinHeight = uniformGridCustomHeightClasses.split(' ')[0];
|
||||||
|
adjustedHeightClasses = `${mobileMinHeight} md:min-h-0`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeline layout for zigzag pattern (works best with 3-6 items)
|
||||||
|
if (gridVariant === "timeline" && itemCount >= 3 && itemCount <= 6) {
|
||||||
|
// Convert depth-3d to scale-rotate for timeline (doesn't support 3D)
|
||||||
|
const timelineAnimationType = animationType === "depth-3d" ? "scale-rotate" : animationType;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TimelineBase
|
||||||
|
variant={gridVariant}
|
||||||
|
uniformGridCustomHeightClasses={adjustedHeightClasses}
|
||||||
|
animationType={timelineAnimationType}
|
||||||
|
title={title}
|
||||||
|
titleSegments={titleSegments}
|
||||||
|
description={description}
|
||||||
|
tag={tag}
|
||||||
|
tagIcon={tagIcon}
|
||||||
|
tagAnimation={tagAnimation}
|
||||||
|
buttons={buttons}
|
||||||
|
buttonAnimation={buttonAnimation}
|
||||||
|
textboxLayout={textboxLayout}
|
||||||
|
useInvertedBackground={useInvertedBackground}
|
||||||
|
className={className}
|
||||||
|
containerClassName={containerClassName}
|
||||||
|
textBoxClassName={textBoxClassName}
|
||||||
|
titleClassName={titleClassName}
|
||||||
|
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||||
|
titleImageClassName={titleImageClassName}
|
||||||
|
descriptionClassName={descriptionClassName}
|
||||||
|
tagClassName={tagClassName}
|
||||||
|
buttonContainerClassName={buttonContainerClassName}
|
||||||
|
buttonClassName={buttonClassName}
|
||||||
|
buttonTextClassName={buttonTextClassName}
|
||||||
|
ariaLabel={ariaLabel}
|
||||||
|
>
|
||||||
|
{childrenArray}
|
||||||
|
</TimelineBase>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use grid for items below threshold, carousel for items at or above threshold
|
||||||
|
// Timeline with 7+ items will also use carousel
|
||||||
|
const useCarousel = itemCount >= carouselThreshold || (gridVariant === "timeline" && itemCount > 6);
|
||||||
|
|
||||||
|
// Grid layout for 1-4 items
|
||||||
|
if (!useCarousel) {
|
||||||
|
return (
|
||||||
|
<GridLayout
|
||||||
|
itemCount={itemCount}
|
||||||
|
gridVariant={gridVariant}
|
||||||
|
uniformGridCustomHeightClasses={adjustedHeightClasses}
|
||||||
|
gridRowsClassName={gridRowsClassName}
|
||||||
|
itemHeightClassesOverride={itemHeightClassesOverride}
|
||||||
|
animationType={animationType}
|
||||||
|
supports3DAnimation={supports3DAnimation}
|
||||||
|
title={title}
|
||||||
|
titleSegments={titleSegments}
|
||||||
|
description={description}
|
||||||
|
tag={tag}
|
||||||
|
tagIcon={tagIcon}
|
||||||
|
tagAnimation={tagAnimation}
|
||||||
|
buttons={buttons}
|
||||||
|
buttonAnimation={buttonAnimation}
|
||||||
|
textboxLayout={textboxLayout}
|
||||||
|
useInvertedBackground={useInvertedBackground}
|
||||||
|
bottomContent={bottomContent}
|
||||||
|
className={className}
|
||||||
|
containerClassName={containerClassName}
|
||||||
|
gridClassName={gridClassName}
|
||||||
|
textBoxClassName={textBoxClassName}
|
||||||
|
titleClassName={titleClassName}
|
||||||
|
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||||
|
titleImageClassName={titleImageClassName}
|
||||||
|
descriptionClassName={descriptionClassName}
|
||||||
|
tagClassName={tagClassName}
|
||||||
|
buttonContainerClassName={buttonContainerClassName}
|
||||||
|
buttonClassName={buttonClassName}
|
||||||
|
buttonTextClassName={buttonTextClassName}
|
||||||
|
ariaLabel={ariaLabel}
|
||||||
|
>
|
||||||
|
{childrenArray}
|
||||||
|
</GridLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-scroll carousel for 5+ items
|
||||||
|
if (mode === "auto") {
|
||||||
|
// Convert depth-3d to scale-rotate for carousel (doesn't support 3D)
|
||||||
|
const carouselAnimationType = animationType === "depth-3d" ? "scale-rotate" : animationType;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AutoCarousel
|
||||||
|
uniformGridCustomHeightClasses={adjustedHeightClasses}
|
||||||
|
animationType={carouselAnimationType}
|
||||||
|
title={title}
|
||||||
|
titleSegments={titleSegments}
|
||||||
|
description={description}
|
||||||
|
tag={tag}
|
||||||
|
tagIcon={tagIcon}
|
||||||
|
tagAnimation={tagAnimation}
|
||||||
|
buttons={buttons}
|
||||||
|
buttonAnimation={buttonAnimation}
|
||||||
|
textboxLayout={textboxLayout}
|
||||||
|
useInvertedBackground={useInvertedBackground}
|
||||||
|
bottomContent={bottomContent}
|
||||||
|
className={className}
|
||||||
|
containerClassName={containerClassName}
|
||||||
|
carouselClassName={carouselClassName}
|
||||||
|
textBoxClassName={textBoxClassName}
|
||||||
|
titleClassName={titleClassName}
|
||||||
|
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||||
|
titleImageClassName={titleImageClassName}
|
||||||
|
descriptionClassName={descriptionClassName}
|
||||||
|
tagClassName={tagClassName}
|
||||||
|
buttonContainerClassName={buttonContainerClassName}
|
||||||
|
buttonClassName={buttonClassName}
|
||||||
|
buttonTextClassName={buttonTextClassName}
|
||||||
|
ariaLabel={ariaLabel}
|
||||||
|
>
|
||||||
|
{childrenArray}
|
||||||
|
</AutoCarousel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button-controlled carousel for 5+ items
|
||||||
|
// Convert depth-3d to scale-rotate for carousel (doesn't support 3D)
|
||||||
|
const carouselAnimationType = animationType === "depth-3d" ? "scale-rotate" : animationType;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonCarousel
|
||||||
|
uniformGridCustomHeightClasses={adjustedHeightClasses}
|
||||||
|
animationType={carouselAnimationType}
|
||||||
|
title={title}
|
||||||
|
titleSegments={titleSegments}
|
||||||
|
description={description}
|
||||||
|
tag={tag}
|
||||||
|
tagIcon={tagIcon}
|
||||||
|
tagAnimation={tagAnimation}
|
||||||
|
buttons={buttons}
|
||||||
|
buttonAnimation={buttonAnimation}
|
||||||
|
textboxLayout={textboxLayout}
|
||||||
|
useInvertedBackground={useInvertedBackground}
|
||||||
|
bottomContent={bottomContent}
|
||||||
|
className={className}
|
||||||
|
containerClassName={containerClassName}
|
||||||
|
carouselClassName={carouselClassName}
|
||||||
|
carouselItemClassName={carouselItemClassName}
|
||||||
|
controlsClassName={controlsClassName}
|
||||||
|
textBoxClassName={textBoxClassName}
|
||||||
|
titleClassName={titleClassName}
|
||||||
|
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||||
|
titleImageClassName={titleImageClassName}
|
||||||
|
descriptionClassName={descriptionClassName}
|
||||||
|
tagClassName={tagClassName}
|
||||||
|
buttonContainerClassName={buttonContainerClassName}
|
||||||
|
buttonClassName={buttonClassName}
|
||||||
|
buttonTextClassName={buttonTextClassName}
|
||||||
|
ariaLabel={ariaLabel}
|
||||||
|
>
|
||||||
|
{childrenArray}
|
||||||
|
</ButtonCarousel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
CardStack.displayName = "CardStack";
|
||||||
|
|
||||||
|
export default memo(CardStack);
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { createContext, useContext, ReactNode } from 'react';
|
|
||||||
|
|
||||||
export interface CardStackContextType {
|
|
||||||
isVisible: boolean;
|
|
||||||
getAnimationProps: () => { isVisible: boolean };
|
|
||||||
itemRefs?: Record<string, HTMLElement | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CardStackContext = createContext<CardStackContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
export function useCardStack() {
|
|
||||||
const context = useContext(CardStackContext);
|
|
||||||
if (!context) {
|
|
||||||
return {
|
|
||||||
isVisible: false,
|
|
||||||
getAnimationProps: () => ({ isVisible: false }),
|
|
||||||
itemRefs: {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CardStackProvider({ children, value }: { children: ReactNode; value: CardStackContextType }) {
|
|
||||||
return (
|
|
||||||
<CardStackContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</CardStackContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CardStackContext;
|
|
||||||
@@ -1,25 +1,148 @@
|
|||||||
import { ReactNode } from 'react';
|
"use client";
|
||||||
import { useCardStack } from '../../CardStackContext';
|
|
||||||
|
|
||||||
export interface AutoCarouselProps {
|
import { memo, Children } from "react";
|
||||||
children: ReactNode;
|
import Marquee from "react-fast-marquee";
|
||||||
className?: string;
|
import CardStackTextBox from "../../CardStackTextBox";
|
||||||
ariaLabel?: string;
|
import { cls } from "@/lib/utils";
|
||||||
}
|
import { AutoCarouselProps } from "../../types";
|
||||||
|
import { useCardAnimation } from "../../hooks/useCardAnimation";
|
||||||
|
|
||||||
export function AutoCarousel({ children, className = '', ariaLabel = 'Auto carousel' }: AutoCarouselProps) {
|
const AutoCarousel = ({
|
||||||
const { isVisible, getAnimationProps } = useCardStack();
|
children,
|
||||||
const animationProps = getAnimationProps();
|
uniformGridCustomHeightClasses,
|
||||||
|
animationType,
|
||||||
|
speed = 50,
|
||||||
|
title,
|
||||||
|
titleSegments,
|
||||||
|
description,
|
||||||
|
tag,
|
||||||
|
tagIcon,
|
||||||
|
tagAnimation,
|
||||||
|
buttons,
|
||||||
|
buttonAnimation,
|
||||||
|
textboxLayout = "default",
|
||||||
|
useInvertedBackground,
|
||||||
|
bottomContent,
|
||||||
|
className = "",
|
||||||
|
containerClassName = "",
|
||||||
|
carouselClassName = "",
|
||||||
|
itemClassName = "",
|
||||||
|
textBoxClassName = "",
|
||||||
|
titleClassName = "",
|
||||||
|
titleImageWrapperClassName = "",
|
||||||
|
titleImageClassName = "",
|
||||||
|
descriptionClassName = "",
|
||||||
|
tagClassName = "",
|
||||||
|
buttonContainerClassName = "",
|
||||||
|
buttonClassName = "",
|
||||||
|
buttonTextClassName = "",
|
||||||
|
ariaLabel,
|
||||||
|
showTextBox = true,
|
||||||
|
dualMarquee = false,
|
||||||
|
topMarqueeDirection = "left",
|
||||||
|
bottomCarouselClassName = "",
|
||||||
|
marqueeGapClassName = "",
|
||||||
|
}: AutoCarouselProps) => {
|
||||||
|
const childrenArray = Children.toArray(children);
|
||||||
|
const heightClasses = uniformGridCustomHeightClasses || "min-h-80 2xl:min-h-90";
|
||||||
|
const { itemRefs, bottomContentRef } = useCardAnimation({
|
||||||
|
animationType,
|
||||||
|
itemCount: childrenArray.length,
|
||||||
|
isGrid: false
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
// Bottom marquee direction is opposite of top
|
||||||
<div
|
const bottomMarqueeDirection = topMarqueeDirection === "left" ? "right" : "left";
|
||||||
className={className}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
data-is-visible={animationProps.isVisible}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AutoCarousel;
|
// Reverse order for bottom marquee to avoid alignment with top
|
||||||
|
const bottomChildren = dualMarquee ? [...childrenArray].reverse() : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cls(
|
||||||
|
"relative py-20 w-full",
|
||||||
|
useInvertedBackground && "bg-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-live="off"
|
||||||
|
>
|
||||||
|
<div className={cls("w-full md:w-content-width mx-auto", containerClassName)}>
|
||||||
|
<div className="w-full flex flex-col items-center">
|
||||||
|
<div className="w-full flex flex-col gap-6">
|
||||||
|
{showTextBox && (title || titleSegments || description) && (
|
||||||
|
<CardStackTextBox
|
||||||
|
title={title}
|
||||||
|
titleSegments={titleSegments}
|
||||||
|
description={description}
|
||||||
|
tag={tag}
|
||||||
|
tagIcon={tagIcon}
|
||||||
|
tagAnimation={tagAnimation}
|
||||||
|
buttons={buttons}
|
||||||
|
buttonAnimation={buttonAnimation}
|
||||||
|
textboxLayout={textboxLayout}
|
||||||
|
useInvertedBackground={useInvertedBackground}
|
||||||
|
textBoxClassName={textBoxClassName}
|
||||||
|
titleClassName={titleClassName}
|
||||||
|
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||||
|
titleImageClassName={titleImageClassName}
|
||||||
|
descriptionClassName={descriptionClassName}
|
||||||
|
tagClassName={tagClassName}
|
||||||
|
buttonContainerClassName={buttonContainerClassName}
|
||||||
|
buttonClassName={buttonClassName}
|
||||||
|
buttonTextClassName={buttonTextClassName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cls(
|
||||||
|
"w-full flex flex-col",
|
||||||
|
marqueeGapClassName || "gap-6"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Top/Single Marquee */}
|
||||||
|
<div className={cls("overflow-hidden w-full relative z-10 mask-padding-x", carouselClassName)}>
|
||||||
|
<Marquee gradient={false} speed={speed} direction={topMarqueeDirection}>
|
||||||
|
{Children.map(childrenArray, (child, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cls("flex-none w-carousel-item-3 xl:w-carousel-item-4 mb-1 mr-6", heightClasses, itemClassName)}
|
||||||
|
ref={(el) => { itemRefs.current[index] = el; }}
|
||||||
|
>
|
||||||
|
{child}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Marquee>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Marquee (only if dualMarquee is true) - Reversed order, opposite direction */}
|
||||||
|
{dualMarquee && (
|
||||||
|
<div className={cls("overflow-hidden w-full relative z-10 mask-padding-x", bottomCarouselClassName || carouselClassName)}>
|
||||||
|
<Marquee gradient={false} speed={speed} direction={bottomMarqueeDirection}>
|
||||||
|
{Children.map(bottomChildren, (child, index) => (
|
||||||
|
<div
|
||||||
|
key={`bottom-${index}`}
|
||||||
|
className={cls("flex-none w-carousel-item-3 xl:w-carousel-item-4 mb-1 mr-6", heightClasses, itemClassName)}
|
||||||
|
>
|
||||||
|
{child}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Marquee>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{bottomContent && (
|
||||||
|
<div ref={bottomContentRef}>
|
||||||
|
{bottomContent}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
AutoCarousel.displayName = "AutoCarousel";
|
||||||
|
|
||||||
|
export default memo(AutoCarousel);
|
||||||
|
|||||||
@@ -1,25 +1,182 @@
|
|||||||
import { ReactNode } from 'react';
|
"use client";
|
||||||
import { useCardStack } from '../../CardStackContext';
|
|
||||||
|
|
||||||
export interface ButtonCarouselProps {
|
import { memo, Children } from "react";
|
||||||
children: ReactNode;
|
import useEmblaCarousel from "embla-carousel-react";
|
||||||
className?: string;
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
ariaLabel?: string;
|
import CardStackTextBox from "../../CardStackTextBox";
|
||||||
}
|
import { cls } from "@/lib/utils";
|
||||||
|
import { ButtonCarouselProps } from "../../types";
|
||||||
|
import { usePrevNextButtons } from "../../hooks/usePrevNextButtons";
|
||||||
|
import { useScrollProgress } from "../../hooks/useScrollProgress";
|
||||||
|
import { useCardAnimation } from "../../hooks/useCardAnimation";
|
||||||
|
|
||||||
export function ButtonCarousel({ children, className = '', ariaLabel = 'Button carousel' }: ButtonCarouselProps) {
|
const ButtonCarousel = ({
|
||||||
const { isVisible, getAnimationProps } = useCardStack();
|
children,
|
||||||
const animationProps = getAnimationProps();
|
uniformGridCustomHeightClasses,
|
||||||
|
animationType,
|
||||||
|
title,
|
||||||
|
titleSegments,
|
||||||
|
description,
|
||||||
|
tag,
|
||||||
|
tagIcon,
|
||||||
|
tagAnimation,
|
||||||
|
buttons,
|
||||||
|
buttonAnimation,
|
||||||
|
textboxLayout = "default",
|
||||||
|
useInvertedBackground,
|
||||||
|
bottomContent,
|
||||||
|
className = "",
|
||||||
|
containerClassName = "",
|
||||||
|
carouselClassName = "",
|
||||||
|
carouselItemClassName = "",
|
||||||
|
controlsClassName = "",
|
||||||
|
textBoxClassName = "",
|
||||||
|
titleClassName = "",
|
||||||
|
titleImageWrapperClassName = "",
|
||||||
|
titleImageClassName = "",
|
||||||
|
descriptionClassName = "",
|
||||||
|
tagClassName = "",
|
||||||
|
buttonContainerClassName = "",
|
||||||
|
buttonClassName = "",
|
||||||
|
buttonTextClassName = "",
|
||||||
|
ariaLabel,
|
||||||
|
}: ButtonCarouselProps) => {
|
||||||
|
const [emblaRef, emblaApi] = useEmblaCarousel({ dragFree: true });
|
||||||
|
|
||||||
return (
|
const {
|
||||||
<div
|
prevBtnDisabled,
|
||||||
className={className}
|
nextBtnDisabled,
|
||||||
aria-label={ariaLabel}
|
onPrevButtonClick,
|
||||||
data-is-visible={animationProps.isVisible}
|
onNextButtonClick,
|
||||||
>
|
} = usePrevNextButtons(emblaApi);
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ButtonCarousel;
|
const scrollProgress = useScrollProgress(emblaApi);
|
||||||
|
|
||||||
|
const childrenArray = Children.toArray(children);
|
||||||
|
const heightClasses = uniformGridCustomHeightClasses || "min-h-80 2xl:min-h-90";
|
||||||
|
const { itemRefs, bottomContentRef } = useCardAnimation({
|
||||||
|
animationType,
|
||||||
|
itemCount: childrenArray.length,
|
||||||
|
isGrid: false
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cls(
|
||||||
|
"relative px-[var(--width-0)] py-20 w-full",
|
||||||
|
useInvertedBackground && "bg-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
<div className={cls("w-full mx-auto", containerClassName)}>
|
||||||
|
<div className="w-full flex flex-col items-center">
|
||||||
|
<div className="w-full flex flex-col gap-6">
|
||||||
|
{(title || titleSegments || description) && (
|
||||||
|
<div className="w-content-width mx-auto">
|
||||||
|
<CardStackTextBox
|
||||||
|
title={title}
|
||||||
|
titleSegments={titleSegments}
|
||||||
|
description={description}
|
||||||
|
tag={tag}
|
||||||
|
tagIcon={tagIcon}
|
||||||
|
tagAnimation={tagAnimation}
|
||||||
|
buttons={buttons}
|
||||||
|
buttonAnimation={buttonAnimation}
|
||||||
|
textboxLayout={textboxLayout}
|
||||||
|
useInvertedBackground={useInvertedBackground}
|
||||||
|
textBoxClassName={textBoxClassName}
|
||||||
|
titleClassName={titleClassName}
|
||||||
|
titleImageWrapperClassName={titleImageWrapperClassName}
|
||||||
|
titleImageClassName={titleImageClassName}
|
||||||
|
descriptionClassName={descriptionClassName}
|
||||||
|
tagClassName={tagClassName}
|
||||||
|
buttonContainerClassName={buttonContainerClassName}
|
||||||
|
buttonClassName={buttonClassName}
|
||||||
|
buttonTextClassName={buttonTextClassName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cls(
|
||||||
|
"w-full flex flex-col gap-6"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cls(
|
||||||
|
"overflow-hidden w-full relative z-10 flex cursor-grab",
|
||||||
|
carouselClassName
|
||||||
|
)}
|
||||||
|
ref={emblaRef}
|
||||||
|
>
|
||||||
|
<div className="flex gap-6 w-full">
|
||||||
|
<div className="flex-shrink-0 w-carousel-padding" />
|
||||||
|
{Children.map(childrenArray, (child, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cls("flex-none select-none w-carousel-item-3 xl:w-carousel-item-4 mb-6", heightClasses, carouselItemClassName)}
|
||||||
|
ref={(el) => { itemRefs.current[index] = el; }}
|
||||||
|
>
|
||||||
|
{child}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex-shrink-0 w-carousel-padding" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cls("w-full flex", controlsClassName)}>
|
||||||
|
<div className="flex-shrink-0 w-carousel-padding-controls" />
|
||||||
|
<div className="flex justify-between items-center w-full">
|
||||||
|
<div
|
||||||
|
className="rounded-theme card relative h-2 w-50 overflow-hidden"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Carousel progress"
|
||||||
|
aria-valuenow={Math.round(scrollProgress)}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-foreground primary-button absolute! w-full top-0 bottom-0 -left-full rounded-theme"
|
||||||
|
style={{ transform: `translate3d(${scrollProgress}%,0px,0px)` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onPrevButtonClick}
|
||||||
|
disabled={prevBtnDisabled}
|
||||||
|
className="secondary-button h-8 aspect-square flex items-center justify-center rounded-theme cursor-pointer transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
type="button"
|
||||||
|
aria-label="Previous slide"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-[40%] w-auto aspect-square text-secondary-cta-text" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onNextButtonClick}
|
||||||
|
disabled={nextBtnDisabled}
|
||||||
|
className="secondary-button h-8 aspect-square flex items-center justify-center rounded-theme cursor-pointer transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
type="button"
|
||||||
|
aria-label="Next slide"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-[40%] w-auto aspect-square text-secondary-cta-text" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0 w-carousel-padding-controls" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{bottomContent && (
|
||||||
|
<div ref={bottomContentRef} className="w-content-width mx-auto">
|
||||||
|
{bottomContent}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ButtonCarousel.displayName = "ButtonCarousel";
|
||||||
|
|
||||||
|
export default memo(ButtonCarousel);
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
|
|
||||||
interface User {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserSession {
|
|
||||||
token: string;
|
|
||||||
user: User;
|
|
||||||
expiresAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuth() {
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
|
||||||
const [token, setToken] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
|
||||||
|
|
||||||
// Initialize auth state from localStorage
|
|
||||||
useEffect(() => {
|
|
||||||
const storedSession = localStorage.getItem("userSession");
|
|
||||||
if (storedSession) {
|
|
||||||
try {
|
|
||||||
const session: UserSession = JSON.parse(storedSession);
|
|
||||||
|
|
||||||
// Check if session has expired
|
|
||||||
if (new Date(session.expiresAt) > new Date()) {
|
|
||||||
setUser(session.user);
|
|
||||||
setToken(session.token);
|
|
||||||
setIsAuthenticated(true);
|
|
||||||
} else {
|
|
||||||
// Session expired, clear it
|
|
||||||
localStorage.removeItem("userSession");
|
|
||||||
setIsAuthenticated(false);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to parse session:", error);
|
|
||||||
localStorage.removeItem("userSession");
|
|
||||||
setIsAuthenticated(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const login = useCallback((sessionData: UserSession) => {
|
|
||||||
setUser(sessionData.user);
|
|
||||||
setToken(sessionData.token);
|
|
||||||
setIsAuthenticated(true);
|
|
||||||
localStorage.setItem("userSession", JSON.stringify(sessionData));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
await fetch("/api/auth/logout", { method: "POST" });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Logout error:", error);
|
|
||||||
} finally {
|
|
||||||
setUser(null);
|
|
||||||
setToken(null);
|
|
||||||
setIsAuthenticated(false);
|
|
||||||
localStorage.removeItem("userSession");
|
|
||||||
window.location.href = "/";
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const verifySession = useCallback(async (): Promise<boolean> => {
|
|
||||||
if (!token) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/auth/verify-session", {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Session verification error:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
const updateUser = useCallback((updatedUser: Partial<User>) => {
|
|
||||||
setUser((prev) => (prev ? { ...prev, ...updatedUser } : null));
|
|
||||||
|
|
||||||
const storedSession = localStorage.getItem("userSession");
|
|
||||||
if (storedSession) {
|
|
||||||
try {
|
|
||||||
const session: UserSession = JSON.parse(storedSession);
|
|
||||||
session.user = { ...session.user, ...updatedUser };
|
|
||||||
localStorage.setItem("userSession", JSON.stringify(session));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update session:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
token,
|
|
||||||
isLoading,
|
|
||||||
isAuthenticated,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
verifySession,
|
|
||||||
updateUser,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user