feat: restaura o projeto original do clube67.com (Next.js + Express API + MySQL + Redis) livre de gambiarras e dockerizado

This commit is contained in:
VPS 4 Builder
2026-05-18 20:57:05 +02:00
parent cb3a71c082
commit b4b0ad0a53
244 changed files with 28512 additions and 16680 deletions
+196
View File
@@ -0,0 +1,196 @@
'use client';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import DashboardLayout from '@/components/layout/DashboardLayout';
import { api } from '@/lib/api';
export default function AdminDashboard() {
const [dashboard, setDashboard] = useState<any>(null);
const [users, setUsers] = useState<any[]>([]);
const [partners, setPartners] = useState<any[]>([]);
const [logs, setLogs] = useState<any[]>([]);
const [tab, setTab] = useState('dashboard');
const [loading, setLoading] = useState(true);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const t = params.get('tab');
if (t) setTab(t);
}, []);
useEffect(() => {
(async () => {
setLoading(true);
try {
if (tab === 'dashboard') {
const d = await api.getDashboard();
setDashboard(d);
} else if (tab === 'users') {
const d = await api.getUsers();
setUsers(d.users || []);
} else if (tab === 'partners') {
const d = await api.getPartners();
setPartners(d.partners || []);
} else if (tab === 'logs') {
const d = await api.getAuditLogs();
setLogs(d.logs || []);
}
} catch (e) { console.error(e); }
setLoading(false);
})();
}, [tab]);
const statCards = dashboard ? [
{ icon: '👥', label: 'Total Usuários', value: dashboard.totalUsers || 0, color: 'from-blue-500 to-cyan-400' },
{ icon: '🏢', label: 'Parceiros', value: dashboard.totalPartners || 0, color: 'from-green-500 to-emerald-400' },
{ icon: '🎁', label: 'Benefícios', value: dashboard.totalBenefits || 0, color: 'from-purple-500 to-pink-400' },
{ icon: '📊', label: 'Transações', value: dashboard.totalTransactions || 0, color: 'from-orange-500 to-red-400' },
] : [];
return (
<DashboardLayout>
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Painel <span className="gradient-text">Administrativo</span></h1>
<p className="text-dark-400 mt-1">Gerencie toda a plataforma em um lugar.</p>
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 overflow-x-auto pb-2">
{[
{ key: 'dashboard', label: '📊 Dashboard' },
{ key: 'users', label: '👥 Usuários' },
{ key: 'partners', label: '🏢 Parceiros' },
{ key: 'benefits', label: '🎁 Benefícios' },
{ key: 'plugins', label: '🔌 Plugins' },
{ key: 'logs', label: '📋 Logs' },
].map(t => (
<button key={t.key} onClick={() => setTab(t.key)}
className={`px-4 py-2 rounded-xl text-sm font-medium whitespace-nowrap transition-all ${tab === t.key ? 'bg-brand-600 text-white' : 'glass text-dark-400 hover:text-white hover:bg-white/5'
}`}>
{t.label}
</button>
))}
</div>
{/* Dashboard Tab */}
{tab === 'dashboard' && (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{loading ? [1, 2, 3, 4].map(i => <div key={i} className="skeleton h-28 rounded-2xl" />) :
statCards.map((card, i) => (
<motion.div key={i} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="glass rounded-2xl p-5">
<div className="flex items-center gap-3 mb-3">
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${card.color} flex items-center justify-center text-lg`}>{card.icon}</div>
<span className="text-dark-400 text-sm">{card.label}</span>
</div>
<div className="text-2xl font-bold">{card.value}</div>
</motion.div>
))}
</div>
</div>
)}
{/* Users Tab */}
{tab === 'users' && (
<div className="glass rounded-2xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-dark-900/50">
<tr>
<th className="text-left px-6 py-4 text-sm text-dark-400 font-medium">Nome</th>
<th className="text-left px-6 py-4 text-sm text-dark-400 font-medium">E-mail</th>
<th className="text-left px-6 py-4 text-sm text-dark-400 font-medium">Função</th>
<th className="text-left px-6 py-4 text-sm text-dark-400 font-medium">Status</th>
<th className="text-left px-6 py-4 text-sm text-dark-400 font-medium">Data</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-800">
{loading ? <tr><td colSpan={5} className="px-6 py-8 text-center text-dark-500">Carregando...</td></tr> :
users.map(u => (
<tr key={u.id} className="hover:bg-dark-800/50 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-brand-500 to-purple-600 flex items-center justify-center text-xs font-bold">
{u.name?.charAt(0)}
</div>
<span className="text-sm font-medium">{u.name}</span>
</div>
</td>
<td className="px-6 py-4 text-sm text-dark-400">{u.email}</td>
<td className="px-6 py-4">
<span className={`text-xs px-2 py-1 rounded-lg font-medium ${u.role === 'super_admin' ? 'bg-red-500/15 text-red-400' :
u.role === 'partner_admin' ? 'bg-blue-500/15 text-blue-400' :
'bg-gray-500/15 text-gray-400'
}`}>{u.role}</span>
</td>
<td className="px-6 py-4">
<span className={`text-xs px-2 py-1 rounded-lg ${u.status === 'active' ? 'bg-green-500/15 text-green-400' : 'bg-yellow-500/15 text-yellow-400'
}`}>{u.status}</span>
</td>
<td className="px-6 py-4 text-sm text-dark-500">{new Date(u.created_at).toLocaleDateString('pt-BR')}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Partners Tab */}
{tab === 'partners' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{loading ? [1, 2, 3].map(i => <div key={i} className="skeleton h-40 rounded-2xl" />) :
partners.map(p => (
<motion.div key={p.id} initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }}
className="glass rounded-2xl p-6 hover:bg-white/5 transition-all cursor-pointer group">
<div className="flex items-center gap-4 mb-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center text-2xl`}
style={{ background: `linear-gradient(135deg, ${p.gradient_from || '#3B82F6'}, ${p.gradient_to || '#8B5CF6'})` }}>
{p.icon || '🏢'}
</div>
<div>
<h3 className="font-bold group-hover:text-brand-400 transition-colors">{p.company_name}</h3>
<p className="text-xs text-dark-500">{p.type} {p.slug}</p>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className={`px-2 py-1 rounded-lg text-xs ${p.status === 'active' ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'
}`}>{p.status === 'active' ? 'Ativo' : 'Inativo'}</span>
<span className="text-dark-500">{p.subscription_plan || 'basic'}</span>
</div>
</motion.div>
))}
</div>
)}
{/* Logs Tab */}
{tab === 'logs' && (
<div className="glass rounded-2xl p-6">
<h2 className="text-lg font-bold mb-4">Logs de Auditoria</h2>
<div className="space-y-2">
{loading ? [1, 2, 3, 4, 5].map(i => <div key={i} className="skeleton h-12 rounded-xl" />) :
logs.length === 0 ? (
<p className="text-dark-500 text-center py-8">Nenhum log registrado.</p>
) : logs.map((l: any) => (
<div key={l.id} className="flex items-center gap-4 p-3 rounded-xl bg-dark-900/50">
<div className="w-9 h-9 rounded-lg bg-brand-500/15 flex items-center justify-center text-brand-400 text-sm">📋</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{l.action}: {l.description || l.entity_type}</p>
<p className="text-xs text-dark-500">{new Date(l.created_at).toLocaleString('pt-BR')}</p>
</div>
<span className="text-xs text-dark-500 shrink-0">{l.ip_address}</span>
</div>
))}
</div>
</div>
)}
</div>
</DashboardLayout>
);
}
+138
View File
@@ -0,0 +1,138 @@
'use client';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import DashboardLayout from '@/components/layout/DashboardLayout';
import { api } from '@/lib/api';
import { useAuth } from '@/lib/auth';
export default function BenefitsPage() {
const { user } = useAuth();
const [benefits, setBenefits] = useState<any[]>([]);
const [categories, setCategories] = useState<any[]>([]);
const [search, setSearch] = useState('');
const [selectedCategory, setSelectedCategory] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
api.getCategories().then(d => setCategories(d.categories || [])).catch(console.error);
loadBenefits();
}, []);
const loadBenefits = async (catId?: string) => {
setLoading(true);
try {
const params = new URLSearchParams();
if (catId) params.set('category', catId);
if (search) params.set('search', search);
const d = await api.getBenefits(params.toString());
setBenefits(d.benefits || []);
} catch (e) { console.error(e); }
setLoading(false);
};
const handleFilter = (catId: string) => {
setSelectedCategory(catId);
loadBenefits(catId);
};
const handleRedeem = async (id: number) => {
try {
await api.redeemBenefit(id);
alert('Benefício resgatado com sucesso! Verifique seu e-mail.');
} catch (err: any) {
alert(err.message || 'Erro ao resgatar');
}
};
const handleFavorite = async (id: number) => {
try {
await api.addFavorite(id);
alert('Adicionado aos favoritos!');
} catch { }
};
return (
<DashboardLayout>
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold">🎁 <span className="gradient-text">Benefícios</span></h1>
<p className="text-dark-400 mt-1">Explore e resgate benefícios exclusivos dos nossos parceiros.</p>
</div>
{/* Search + Filter */}
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<input type="text" value={search} onChange={e => setSearch(e.target.value)}
onKeyDown={e => e.key === 'Enter' && loadBenefits(selectedCategory)}
placeholder="Buscar benefícios..."
className="w-full px-4 py-3 bg-dark-900 border border-dark-700 rounded-xl text-white focus:border-brand-500 outline-none" />
</div>
<div className="flex gap-2 overflow-x-auto pb-2">
<button onClick={() => handleFilter('')}
className={`px-4 py-2 rounded-xl text-sm whitespace-nowrap transition-all ${!selectedCategory ? 'bg-brand-600 text-white' : 'glass text-dark-400 hover:text-white'
}`}>Todos</button>
{categories.map(c => (
<button key={c.id} onClick={() => handleFilter(c.id.toString())}
className={`px-4 py-2 rounded-xl text-sm whitespace-nowrap transition-all ${selectedCategory === c.id.toString() ? 'bg-brand-600 text-white' : 'glass text-dark-400 hover:text-white'
}`}>
{c.icon} {c.name}
</button>
))}
</div>
</div>
{/* Benefits Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{loading ? [1, 2, 3, 4, 5, 6].map(i => <div key={i} className="skeleton h-56 rounded-2xl" />) :
benefits.length === 0 ? (
<div className="col-span-full text-center py-16 glass rounded-2xl">
<div className="text-5xl mb-4">🔍</div>
<p className="text-xl font-semibold mb-2">Nenhum benefício encontrado</p>
<p className="text-dark-400">Tente ajustar os filtros de busca.</p>
</div>
) :
benefits.map((b, i) => (
<motion.div key={b.id} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
className="glass rounded-2xl overflow-hidden hover:bg-white/5 transition-all group">
{/* Gradient Header */}
<div className="h-2" style={{ background: `linear-gradient(to right, ${b.gradient_from || '#3B82F6'}, ${b.gradient_to || '#8B5CF6'})` }} />
<div className="p-5">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="font-bold text-lg group-hover:text-brand-400 transition-colors">{b.title}</h3>
<p className="text-xs text-dark-500 mt-1">{b.partner_name || 'Parceiro'}</p>
</div>
{b.discount_percent && (
<div className="bg-green-500/15 text-green-400 text-sm font-bold px-3 py-1 rounded-lg">
-{b.discount_percent}%
</div>
)}
</div>
<p className="text-sm text-dark-400 mb-4 line-clamp-2">{b.description}</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{b.badge && <span className="text-xs px-2 py-1 rounded-lg bg-purple-500/15 text-purple-400">{b.badge}</span>}
{b.is_global && <span className="text-xs px-2 py-1 rounded-lg bg-brand-500/15 text-brand-400">🌐 Global</span>}
</div>
<div className="flex gap-2">
<button onClick={() => handleFavorite(b.id)}
className="p-2 rounded-lg hover:bg-yellow-500/15 text-dark-400 hover:text-yellow-400 transition-all" title="Favoritar">
</button>
<button onClick={() => handleRedeem(b.id)}
className="px-4 py-2 bg-brand-600 hover:bg-brand-500 rounded-lg text-sm font-semibold transition-all">
Resgatar
</button>
</div>
</div>
</div>
</motion.div>
))}
</div>
</div>
</DashboardLayout>
);
}
+127
View File
@@ -0,0 +1,127 @@
'use client';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import DashboardLayout from '@/components/layout/DashboardLayout';
import { api } from '@/lib/api';
import { useAuth } from '@/lib/auth';
interface Stats {
totalBenefits?: number;
totalFavorites?: number;
totalRedemptions?: number;
totalSavings?: number;
}
export default function UserDashboard() {
const { user } = useAuth();
const [stats, setStats] = useState<Stats>({});
const [favorites, setFavorites] = useState<any[]>([]);
const [history, setHistory] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
api.getUserStats().catch(() => ({})),
api.getFavorites().catch(() => ({ favorites: [] })),
api.getHistory().catch(() => ({ transactions: [] })),
]).then(([s, f, h]) => {
setStats(s);
setFavorites(f.favorites || []);
setHistory(h.transactions || []);
setLoading(false);
});
}, []);
const statCards = [
{ icon: '🎁', label: 'Benefícios Disponíveis', value: stats.totalBenefits || 0, color: 'from-blue-500 to-cyan-400' },
{ icon: '⭐', label: 'Favoritos', value: stats.totalFavorites || 0, color: 'from-yellow-500 to-orange-400' },
{ icon: '✅', label: 'Resgates', value: stats.totalRedemptions || 0, color: 'from-green-500 to-emerald-400' },
{ icon: '💰', label: 'Economia Total', value: `R$ ${(stats.totalSavings || 0).toFixed(0)}`, color: 'from-purple-500 to-pink-400' },
];
return (
<DashboardLayout>
<div className="space-y-8">
{/* Header */}
<div>
<h1 className="text-3xl font-bold">
Olá, <span className="gradient-text">{user?.name?.split(' ')[0]}</span> 👋
</h1>
<p className="text-dark-400 mt-1">Confira seus benefícios e acompanhe sua economia.</p>
</div>
{/* Stat Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{statCards.map((card, i) => (
<motion.div key={i} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="glass rounded-2xl p-5 hover:bg-white/5 transition-all">
<div className="flex items-center gap-3 mb-3">
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${card.color} flex items-center justify-center text-lg`}>
{card.icon}
</div>
<span className="text-dark-400 text-sm">{card.label}</span>
</div>
<div className="text-2xl font-bold">{loading ? <div className="skeleton h-8 w-20" /> : card.value}</div>
</motion.div>
))}
</div>
{/* Content Grid */}
<div className="grid lg:grid-cols-2 gap-6">
{/* Recent History */}
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.3 }}
className="glass rounded-2xl p-6">
<h2 className="text-lg font-bold mb-4">Resgates Recentes</h2>
{loading ? (
<div className="space-y-3">{[1, 2, 3].map(i => <div key={i} className="skeleton h-14 w-full" />)}</div>
) : history.length === 0 ? (
<p className="text-dark-500 text-center py-8">Nenhum resgate ainda. Explore os benefícios!</p>
) : (
<div className="space-y-3">
{history.slice(0, 5).map((t: any) => (
<div key={t.id} className="flex items-center justify-between p-3 rounded-xl bg-dark-900/50">
<div>
<p className="text-sm font-medium">{t.benefit_title || 'Benefício'}</p>
<p className="text-xs text-dark-500">{new Date(t.created_at).toLocaleDateString('pt-BR')}</p>
</div>
<span className={`text-xs px-2 py-1 rounded-lg ${t.status === 'completed' ? 'bg-green-500/15 text-green-400' : 'bg-yellow-500/15 text-yellow-400'
}`}>{t.status === 'completed' ? 'Resgatado' : 'Pendente'}</span>
</div>
))}
</div>
)}
</motion.div>
{/* Favorites */}
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.4 }}
className="glass rounded-2xl p-6">
<h2 className="text-lg font-bold mb-4"> Favoritos</h2>
{loading ? (
<div className="space-y-3">{[1, 2, 3].map(i => <div key={i} className="skeleton h-14 w-full" />)}</div>
) : favorites.length === 0 ? (
<p className="text-dark-500 text-center py-8">Nenhum favorito salvo ainda.</p>
) : (
<div className="space-y-3">
{favorites.slice(0, 5).map((f: any) => (
<div key={f.id} className="flex items-center justify-between p-3 rounded-xl bg-dark-900/50">
<div>
<p className="text-sm font-medium">{f.title}</p>
<p className="text-xs text-dark-500">{f.partner_name || 'Parceiro'}</p>
</div>
{f.discount_percent && (
<span className="text-xs px-2 py-1 rounded-lg bg-brand-500/15 text-brand-400 font-semibold">
-{f.discount_percent}%
</span>
)}
</div>
))}
</div>
)}
</motion.div>
</div>
</div>
</DashboardLayout>
);
}
+72
View File
@@ -0,0 +1,72 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
:root {
--brand: #2a8bff;
--brand-dark: #0d54e1;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #4b5563; }
/* Dark mode scrollbar */
.dark ::-webkit-scrollbar-thumb { background: #4b5563; }
.dark ::-webkit-scrollbar-thumb:hover { background: #6b7280; }
/* Glass effect */
.glass {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.glass-dark {
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.06);
}
/* Gradient text */
.gradient-text {
background: linear-gradient(135deg, #2a8bff 0%, #a855f7 50%, #ec4899 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Skeleton loading */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, #1e1f2b 25%, #2a2b3d 50%, #1e1f2b 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8px;
}
/* Focus ring */
.focus-ring:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(42, 139, 255, 0.4);
}
@layer utilities {
.text-balance { text-wrap: balance; }
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from 'next';
import './globals.css';
import { AuthProvider } from '@/lib/auth';
export const metadata: Metadata = {
title: 'Clube de Benefícios | Clube67',
description: 'Plataforma de benefícios exclusivos para membros. Descontos, vantagens e muito mais com parceiros selecionados.',
keywords: 'benefícios, descontos, clube, parceiros, vantagens',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="pt-BR" className="dark">
<body className="bg-dark-950 text-white min-h-screen antialiased">
<AuthProvider>
{children}
</AuthProvider>
</body>
</html>
);
}
+86
View File
@@ -0,0 +1,86 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { useAuth } from '@/lib/auth';
export default function LoginPage() {
const { login } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(email, password);
} catch (err: any) {
setError(err.message || 'Erro ao fazer login');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center px-4 relative">
<div className="absolute top-20 left-10 w-72 h-72 bg-brand-600/20 rounded-full blur-[100px]" />
<div className="absolute bottom-20 right-10 w-96 h-96 bg-purple-600/15 rounded-full blur-[120px]" />
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}
className="w-full max-w-md relative z-10">
<div className="text-center mb-8">
<Link href="/" className="text-3xl font-bold gradient-text">Clube67</Link>
<h1 className="text-2xl font-bold mt-4">Bem-vindo de volta</h1>
<p className="text-dark-400 mt-2">Entre na sua conta para continuar</p>
</div>
<div className="glass rounded-2xl p-8">
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<div className="bg-red-500/10 border border-red-500/30 text-red-400 px-4 py-3 rounded-xl text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">E-mail</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required
className="w-full px-4 py-3 bg-dark-900 border border-dark-700 rounded-xl text-white focus:border-brand-500 focus:ring-1 focus:ring-brand-500/50 transition-all outline-none"
placeholder="seu@email.com" />
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">Senha</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required
className="w-full px-4 py-3 bg-dark-900 border border-dark-700 rounded-xl text-white focus:border-brand-500 focus:ring-1 focus:ring-brand-500/50 transition-all outline-none"
placeholder="••••••••" />
</div>
<button type="submit" disabled={loading}
className="w-full py-3.5 bg-brand-600 hover:bg-brand-500 rounded-xl font-semibold transition-all shadow-lg shadow-brand-600/25 disabled:opacity-50 disabled:cursor-not-allowed">
{loading ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" className="opacity-25" /><path d="M4 12a8 8 0 018-8" stroke="currentColor" strokeWidth="4" fill="none" strokeLinecap="round" /></svg>
Entrando...
</span>
) : 'Entrar'}
</button>
</form>
<div className="mt-6 text-center">
<p className="text-dark-400 text-sm">
Não tem conta?{' '}
<Link href="/register" className="text-brand-400 hover:text-brand-300 font-medium">
Criar conta
</Link>
</p>
</div>
</div>
</motion.div>
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { useAuth } from '@/lib/auth';
const features = [
{ icon: '🎯', title: 'Benefícios Exclusivos', desc: 'Descontos e vantagens em parceiros selecionados da sua região.' },
{ icon: '💳', title: 'Cartão Digital', desc: 'Apresente seu QR Code e resgate benefícios de forma rápida e segura.' },
{ icon: '🏢', title: 'Parceiros Premium', desc: 'Rede de empresas verificadas oferecendo os melhores descontos.' },
{ icon: '📊', title: 'Painel Completo', desc: 'Acompanhe seus resgates, economia e benefícios favoritos em tempo real.' },
];
const stats = [
{ value: '500+', label: 'Benefícios Ativos' },
{ value: '150+', label: 'Parceiros' },
{ value: '10k+', label: 'Membros' },
{ value: 'R$ 2M+', label: 'Economia Gerada' },
];
export default function HomePage() {
const { user } = useAuth();
return (
<div className="min-h-screen bg-dark-950 overflow-hidden">
{/* Header */}
<header className="fixed top-0 left-0 right-0 z-50 glass-dark">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<Link href="/" className="flex items-center gap-2">
<span className="text-2xl font-bold gradient-text">Clube67</span>
</Link>
<nav className="hidden md:flex items-center gap-8">
<a href="#features" className="text-dark-300 hover:text-white transition-colors text-sm font-medium">Recursos</a>
<a href="#partners" className="text-dark-300 hover:text-white transition-colors text-sm font-medium">Parceiros</a>
<a href="#pricing" className="text-dark-300 hover:text-white transition-colors text-sm font-medium">Planos</a>
</nav>
<div className="flex items-center gap-3">
{user ? (
<Link href="/dashboard" className="px-5 py-2.5 bg-brand-600 hover:bg-brand-700 rounded-xl text-sm font-semibold transition-all">
Painel
</Link>
) : (
<>
<Link href="/login" className="px-5 py-2.5 text-dark-300 hover:text-white text-sm font-medium transition-colors">
Entrar
</Link>
<Link href="/register" className="px-5 py-2.5 bg-brand-600 hover:bg-brand-700 rounded-xl text-sm font-semibold transition-all shadow-lg shadow-brand-600/25">
Criar Conta
</Link>
</>
)}
</div>
</div>
</header>
{/* Hero */}
<section className="relative pt-32 pb-20 px-6">
{/* Background orbs */}
<div className="absolute top-20 left-10 w-72 h-72 bg-brand-600/20 rounded-full blur-[100px]" />
<div className="absolute top-40 right-10 w-96 h-96 bg-purple-600/15 rounded-full blur-[120px]" />
<div className="absolute bottom-10 left-1/3 w-80 h-80 bg-pink-600/10 rounded-full blur-[100px]" />
<div className="max-w-5xl mx-auto text-center relative z-10">
<motion.div
initial={{ opacity: 0, y: 30 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.7 }}
>
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass text-sm text-brand-400 font-medium mb-8">
<span className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
Plataforma ativa novos parceiros toda semana
</div>
<h1 className="text-5xl md:text-7xl font-extrabold leading-tight mb-6">
Seus benefícios em
<br />
<span className="gradient-text">um lugar</span>
</h1>
<p className="text-xl text-dark-400 max-w-2xl mx-auto mb-10 leading-relaxed">
Conectamos você a descontos exclusivos em saúde, educação, gastronomia e muito mais.
Tudo em uma plataforma moderna e segura.
</p>
<div className="flex items-center justify-center gap-4 flex-wrap">
<Link href="/register"
className="px-8 py-4 bg-brand-600 hover:bg-brand-500 rounded-2xl text-lg font-bold transition-all shadow-2xl shadow-brand-600/30 hover:shadow-brand-500/40 hover:-translate-y-0.5">
Começar Agora
</Link>
<Link href="#features"
className="px-8 py-4 glass hover:bg-white/10 rounded-2xl text-lg font-medium transition-all">
Como Funciona
</Link>
</div>
</motion.div>
</div>
</section>
{/* Stats */}
<section className="py-16 px-6">
<div className="max-w-5xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-6">
{stats.map((s, i) => (
<motion.div key={i} initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }} viewport={{ once: true }}
className="glass rounded-2xl p-6 text-center">
<div className="text-3xl md:text-4xl font-extrabold gradient-text">{s.value}</div>
<div className="text-dark-400 text-sm mt-1">{s.label}</div>
</motion.div>
))}
</div>
</section>
{/* Features */}
<section id="features" className="py-20 px-6">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<h2 className="text-4xl font-bold mb-4">Tudo que você precisa</h2>
<p className="text-dark-400 text-lg">Uma plataforma completa de benefícios para você e sua família.</p>
</div>
<div className="grid md:grid-cols-2 gap-6">
{features.map((f, i) => (
<motion.div key={i} initial={{ opacity: 0, x: i % 2 === 0 ? -20 : 20 }}
whileInView={{ opacity: 1, x: 0 }} transition={{ delay: i * 0.1 }} viewport={{ once: true }}
className="glass rounded-2xl p-8 hover:bg-white/5 transition-all group cursor-default">
<div className="text-4xl mb-4">{f.icon}</div>
<h3 className="text-xl font-bold mb-2 group-hover:text-brand-400 transition-colors">{f.title}</h3>
<p className="text-dark-400 leading-relaxed">{f.desc}</p>
</motion.div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className="py-20 px-6">
<div className="max-w-4xl mx-auto">
<motion.div initial={{ opacity: 0, scale: 0.95 }} whileInView={{ opacity: 1, scale: 1 }} viewport={{ once: true }}
className="relative overflow-hidden rounded-3xl p-12 md:p-16 text-center"
style={{ background: 'linear-gradient(135deg, #1469f5 0%, #7c3aed 50%, #ec4899 100%)' }}>
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iMiIgY3k9IjIiIHI9IjEiIGZpbGw9InJnYmEoMjU1LDI1NSwyNTUsMC4xKSIvPjwvc3ZnPg==')] opacity-30" />
<h2 className="text-4xl md:text-5xl font-extrabold mb-4 relative z-10">
Pronto para economizar?
</h2>
<p className="text-xl text-white/80 mb-8 relative z-10">
Junte-se a milhares de membros que estão aproveitando benefícios exclusivos.
</p>
<Link href="/register"
className="relative z-10 inline-block px-10 py-4 bg-white text-brand-700 font-bold text-lg rounded-2xl hover:bg-gray-100 transition-all shadow-2xl hover:-translate-y-1">
Criar Conta Grátis
</Link>
</motion.div>
</div>
</section>
{/* Footer */}
<footer className="border-t border-dark-800 py-12 px-6">
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center justify-between gap-6">
<span className="text-xl font-bold gradient-text">Clube67</span>
<div className="flex items-center gap-6 text-sm text-dark-400">
<a href="#" className="hover:text-white transition-colors">Termos</a>
<a href="#" className="hover:text-white transition-colors">Privacidade</a>
<a href="#" className="hover:text-white transition-colors">Suporte</a>
</div>
<p className="text-sm text-dark-500">© 2026 Clube67. Todos os direitos reservados.</p>
</div>
</footer>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
'use client';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import DashboardLayout from '@/components/layout/DashboardLayout';
import { api } from '@/lib/api';
import { useAuth } from '@/lib/auth';
export default function PartnerDashboard() {
const { user } = useAuth();
const [stats, setStats] = useState<any>(null);
const [tab, setTab] = useState('dashboard');
const [loading, setLoading] = useState(true);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const t = params.get('tab');
if (t) setTab(t);
}, []);
useEffect(() => {
api.getPartnerStats(1).then(d => { setStats(d); setLoading(false); }).catch(() => setLoading(false));
}, []);
const statCards = stats ? [
{ icon: '🎁', label: 'Benefícios Ativos', value: stats.activeBenefits || 0, color: 'from-blue-500 to-cyan-400' },
{ icon: '👥', label: 'Membros', value: stats.totalMembers || 0, color: 'from-green-500 to-emerald-400' },
{ icon: '✅', label: 'Resgates este Mês', value: stats.monthlyRedemptions || 0, color: 'from-purple-500 to-pink-400' },
{ icon: '📈', label: 'Meta Mensal', value: `${stats.goalProgress || 0}%`, color: 'from-orange-500 to-red-400' },
] : [];
return (
<DashboardLayout>
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold">Painel do <span className="gradient-text">Parceiro</span></h1>
<p className="text-dark-400 mt-1">Gerencie seus benefícios e acompanhe o desempenho.</p>
</div>
{/* Stat Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{loading ? [1, 2, 3, 4].map(i => <div key={i} className="skeleton h-28 rounded-2xl" />) :
statCards.map((card, i) => (
<motion.div key={i} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="glass rounded-2xl p-5">
<div className="flex items-center gap-3 mb-3">
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${card.color} flex items-center justify-center text-lg`}>{card.icon}</div>
<span className="text-dark-400 text-sm">{card.label}</span>
</div>
<div className="text-2xl font-bold">{card.value}</div>
</motion.div>
))}
</div>
{/* Quick Actions */}
<div className="glass rounded-2xl p-6">
<h2 className="text-lg font-bold mb-4">Ações Rápidas</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ icon: '', label: 'Novo Benefício', action: () => { } },
{ icon: '📊', label: 'Relatórios', action: () => { } },
{ icon: '👥', label: 'Ver Membros', action: () => { } },
{ icon: '⚙️', label: 'Configurações', action: () => { } },
].map((a, i) => (
<button key={i} onClick={a.action}
className="p-4 rounded-xl bg-dark-900/50 hover:bg-dark-800 transition-all text-center group">
<span className="text-2xl block mb-2">{a.icon}</span>
<span className="text-sm text-dark-400 group-hover:text-white transition-colors">{a.label}</span>
</button>
))}
</div>
</div>
</div>
</DashboardLayout>
);
}
+85
View File
@@ -0,0 +1,85 @@
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
import DashboardLayout from '@/components/layout/DashboardLayout';
import { api } from '@/lib/api';
import { useAuth } from '@/lib/auth';
export default function ProfilePage() {
const { user } = useAuth();
const [form, setForm] = useState({
name: user?.name || '',
phone: user?.phone || '',
});
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
const handleSave = async () => {
setSaving(true);
setMsg('');
try {
if (user) {
await api.updateUser(user.id, form);
setMsg('Perfil atualizado com sucesso!');
}
} catch (err: any) {
setMsg(err.message || 'Erro ao salvar');
}
setSaving(false);
};
return (
<DashboardLayout>
<div className="max-w-2xl space-y-8">
<div>
<h1 className="text-3xl font-bold">👤 <span className="gradient-text">Meu Perfil</span></h1>
<p className="text-dark-400 mt-1">Gerencie suas informações pessoais.</p>
</div>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}
className="glass rounded-2xl p-8">
{/* Avatar */}
<div className="flex items-center gap-5 mb-8">
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-brand-500 to-purple-600 flex items-center justify-center text-3xl font-bold">
{user?.name?.charAt(0) || '?'}
</div>
<div>
<p className="text-xl font-bold">{user?.name}</p>
<p className="text-dark-400">{user?.email}</p>
<span className="text-xs px-2 py-1 rounded-lg bg-brand-500/15 text-brand-400 mt-1 inline-block">{user?.role}</span>
</div>
</div>
{msg && (
<div className={`mb-6 px-4 py-3 rounded-xl text-sm ${msg.includes('sucesso') ? 'bg-green-500/10 text-green-400 border border-green-500/30' : 'bg-red-500/10 text-red-400 border border-red-500/30'
}`}>{msg}</div>
)}
<div className="space-y-5">
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">Nome</label>
<input type="text" value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))}
className="w-full px-4 py-3 bg-dark-900 border border-dark-700 rounded-xl text-white focus:border-brand-500 outline-none" />
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">E-mail</label>
<input type="email" value={user?.email || ''} disabled
className="w-full px-4 py-3 bg-dark-800 border border-dark-700 rounded-xl text-dark-400 cursor-not-allowed" />
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">Telefone</label>
<input type="tel" value={form.phone} onChange={e => setForm(p => ({ ...p, phone: e.target.value }))}
className="w-full px-4 py-3 bg-dark-900 border border-dark-700 rounded-xl text-white focus:border-brand-500 outline-none"
placeholder="(67) 99999-0000" />
</div>
<button onClick={handleSave} disabled={saving}
className="px-8 py-3 bg-brand-600 hover:bg-brand-500 rounded-xl font-semibold transition-all shadow-lg shadow-brand-600/25 disabled:opacity-50">
{saving ? 'Salvando...' : 'Salvar Alterações'}
</button>
</div>
</motion.div>
</div>
</DashboardLayout>
);
}
+92
View File
@@ -0,0 +1,92 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { motion } from 'framer-motion';
import { useAuth } from '@/lib/auth';
export default function RegisterPage() {
const { register } = useAuth();
const [form, setForm] = useState({ name: '', email: '', password: '', phone: '' });
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await register(form);
} catch (err: any) {
setError(err.message || 'Erro ao criar conta');
} finally {
setLoading(false);
}
};
const update = (k: string, v: string) => setForm(p => ({ ...p, [k]: v }));
return (
<div className="min-h-screen flex items-center justify-center px-4 py-12 relative">
<div className="absolute top-20 right-10 w-72 h-72 bg-purple-600/20 rounded-full blur-[100px]" />
<div className="absolute bottom-20 left-10 w-96 h-96 bg-brand-600/15 rounded-full blur-[120px]" />
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}
className="w-full max-w-md relative z-10">
<div className="text-center mb-8">
<Link href="/" className="text-3xl font-bold gradient-text">Clube67</Link>
<h1 className="text-2xl font-bold mt-4">Criar Conta</h1>
<p className="text-dark-400 mt-2">Junte-se ao clube e aproveite benefícios exclusivos</p>
</div>
<div className="glass rounded-2xl p-8">
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<div className="bg-red-500/10 border border-red-500/30 text-red-400 px-4 py-3 rounded-xl text-sm">{error}</div>
)}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">Nome completo</label>
<input type="text" value={form.name} onChange={e => update('name', e.target.value)} required
className="w-full px-4 py-3 bg-dark-900 border border-dark-700 rounded-xl text-white focus:border-brand-500 focus:ring-1 focus:ring-brand-500/50 transition-all outline-none"
placeholder="Seu nome" />
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">E-mail</label>
<input type="email" value={form.email} onChange={e => update('email', e.target.value)} required
className="w-full px-4 py-3 bg-dark-900 border border-dark-700 rounded-xl text-white focus:border-brand-500 focus:ring-1 focus:ring-brand-500/50 transition-all outline-none"
placeholder="seu@email.com" />
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">Telefone</label>
<input type="tel" value={form.phone} onChange={e => update('phone', e.target.value)}
className="w-full px-4 py-3 bg-dark-900 border border-dark-700 rounded-xl text-white focus:border-brand-500 focus:ring-1 focus:ring-brand-500/50 transition-all outline-none"
placeholder="(67) 99999-0000" />
</div>
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">Senha</label>
<input type="password" value={form.password} onChange={e => update('password', e.target.value)} required minLength={6}
className="w-full px-4 py-3 bg-dark-900 border border-dark-700 rounded-xl text-white focus:border-brand-500 focus:ring-1 focus:ring-brand-500/50 transition-all outline-none"
placeholder="Mínimo 6 caracteres" />
</div>
<button type="submit" disabled={loading}
className="w-full py-3.5 bg-brand-600 hover:bg-brand-500 rounded-xl font-semibold transition-all shadow-lg shadow-brand-600/25 disabled:opacity-50">
{loading ? 'Criando conta...' : 'Criar Conta'}
</button>
</form>
<div className="mt-6 text-center">
<p className="text-dark-400 text-sm">
tem uma conta?{' '}
<Link href="/login" className="text-brand-400 hover:text-brand-300 font-medium">Entrar</Link>
</p>
</div>
</div>
</motion.div>
</div>
);
}
@@ -0,0 +1,37 @@
'use client';
import { useAuth } from '@/lib/auth';
import Sidebar from '@/components/layout/Sidebar';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && !user) router.push('/login');
}, [loading, user, router]);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="w-12 h-12 border-4 border-brand-600 border-t-transparent rounded-full animate-spin mx-auto" />
<p className="text-dark-400 mt-4">Carregando...</p>
</div>
</div>
);
}
if (!user) return null;
return (
<div className="min-h-screen flex">
<Sidebar />
<main className="flex-1 ml-64 p-6 md:p-8">
{children}
</main>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useAuth } from '@/lib/auth';
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';
const userMenu = [
{ icon: '📊', label: 'Painel', href: '/dashboard' },
{ icon: '🎁', label: 'Benefícios', href: '/benefits' },
{ icon: '⭐', label: 'Favoritos', href: '/dashboard?tab=favorites' },
{ icon: '📜', label: 'Histórico', href: '/dashboard?tab=history' },
{ icon: '👤', label: 'Perfil', href: '/profile' },
];
const adminMenu = [
{ icon: '📊', label: 'Dashboard', href: '/admin' },
{ icon: '👥', label: 'Usuários', href: '/admin?tab=users' },
{ icon: '🏢', label: 'Parceiros', href: '/admin?tab=partners' },
{ icon: '🎁', label: 'Benefícios', href: '/admin?tab=benefits' },
{ icon: '🔌', label: 'Plugins', href: '/admin?tab=plugins' },
{ icon: '📋', label: 'Logs', href: '/admin?tab=logs' },
{ icon: '⚙️', label: 'Configurações', href: '/settings' },
];
const partnerMenu = [
{ icon: '📊', label: 'Dashboard', href: '/partner' },
{ icon: '🎁', label: 'Benefícios', href: '/partner?tab=benefits' },
{ icon: '👥', label: 'Membros', href: '/partner?tab=members' },
{ icon: '📈', label: 'Relatórios', href: '/partner?tab=reports' },
{ icon: '⚙️', label: 'Configurações', href: '/settings' },
];
export default function Sidebar() {
const { user, logout, isAdmin, isPartner } = useAuth();
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);
const menu = isAdmin ? adminMenu : isPartner ? partnerMenu : userMenu;
return (
<aside className={`fixed left-0 top-0 h-full bg-dark-900/80 backdrop-blur-xl border-r border-dark-800 z-40 transition-all duration-300 ${collapsed ? 'w-20' : 'w-64'}`}>
{/* Logo */}
<div className="flex items-center justify-between px-6 py-5 border-b border-dark-800">
{!collapsed && <Link href="/" className="text-xl font-bold gradient-text">Clube67</Link>}
<button onClick={() => setCollapsed(!collapsed)} className="p-2 rounded-lg hover:bg-dark-800 transition-colors text-dark-400 hover:text-white">
{collapsed ? '→' : '←'}
</button>
</div>
{/* Nav */}
<nav className="mt-4 px-3 space-y-1">
{menu.map(item => {
const active = pathname === item.href || (item.href !== '/' && pathname.startsWith(item.href.split('?')[0]));
return (
<Link key={item.href} href={item.href}
className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all ${active ? 'bg-brand-600/15 text-brand-400 border border-brand-500/20' : 'text-dark-400 hover:text-white hover:bg-dark-800'
}`}>
<span className="text-lg">{item.icon}</span>
{!collapsed && <span>{item.label}</span>}
</Link>
);
})}
</nav>
{/* User */}
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-dark-800">
<div className={`flex items-center gap-3 ${collapsed ? 'justify-center' : ''}`}>
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-brand-500 to-purple-600 flex items-center justify-center text-sm font-bold shrink-0">
{user?.name?.charAt(0) || '?'}
</div>
{!collapsed && (
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{user?.name}</p>
<p className="text-xs text-dark-500 truncate">{user?.email}</p>
</div>
)}
</div>
{!collapsed && (
<button onClick={logout}
className="w-full mt-3 py-2 text-sm text-dark-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-all">
Sair da conta
</button>
)}
</div>
</aside>
);
}
-330
View File
@@ -1,330 +0,0 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Clube67 — Clube de Benefícios Premium</title>
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Outfit:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<!-- Stylesheet -->
<link rel="stylesheet" href="style.css">
</head>
<body class="bg-slate-950 text-slate-100 font-sans min-h-screen selection:bg-indigo-500/30 selection:text-indigo-200 overflow-x-hidden">
<!-- Dynamic Background Orbs -->
<div class="fixed -top-40 -left-40 w-96 h-96 rounded-full bg-indigo-600/10 blur-[120px] pointer-events-none animate-pulse duration-[8000ms]"></div>
<div class="fixed top-1/2 -right-40 w-[500px] h-[500px] rounded-full bg-violet-600/10 blur-[150px] pointer-events-none animate-pulse duration-[10000ms]"></div>
<!-- Top Header -->
<header class="sticky top-0 z-40 backdrop-blur-md bg-slate-950/75 border-b border-slate-900/80 px-6 py-4">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-tr from-indigo-600 to-violet-500 flex items-center justify-center font-display font-black text-xl text-white shadow-lg shadow-logo">
67
</div>
<div>
<h1 class="font-display font-black text-lg tracking-wide text-white uppercase leading-none">Clube67</h1>
<span class="brand-subtitle font-bold text-slate-500 uppercase">Premium Benefícios</span>
</div>
</div>
<!-- Navigation -->
<nav class="flex bg-slate-900/60 p-1 rounded-xl border border-slate-800/80">
<button id="tab-catalog" class="nav-tab active flex items-center gap-2 px-5 py-2.5 rounded-lg text-xs font-bold tracking-wide uppercase transition-all duration-300">
<i data-lucide="tag" class="w-3.5 h-3.5"></i> Catálogo
</button>
<button id="tab-redeem" class="nav-tab flex items-center gap-2 px-5 py-2.5 rounded-lg text-xs font-bold tracking-wide uppercase transition-all duration-300">
<i data-lucide="qr-code" class="w-3.5 h-3.5"></i> Resgate
</button>
<button id="tab-analytics" class="nav-tab flex items-center gap-2 px-5 py-2.5 rounded-lg text-xs font-bold tracking-wide uppercase transition-all duration-300">
<i data-lucide="bar-chart-3" class="w-3.5 h-3.5"></i> Analytics
</button>
</nav>
</div>
</header>
<main class="max-w-7xl mx-auto px-6 py-12">
<!-- SECTION 1: CATALOG -->
<section id="section-catalog" class="content-section">
<div class="mb-10 text-center md:text-left">
<h2 class="font-display font-black text-3xl md:text-4xl text-white tracking-tight mb-3">
Seus Benefícios <span class="bg-gradient-to-r from-indigo-400 to-violet-400 bg-clip-text text-transparent">Exclusivos</span>
</h2>
<p class="text-slate-400 text-sm max-w-2xl leading-relaxed">
Explore descontos e vantagens premium selecionados sob medida para você nos melhores estabelecimentos parceiros do Mato Grosso do Sul.
</p>
</div>
<!-- Search & Filters Container -->
<div class="bg-slate-900/40 border border-slate-800/50 rounded-2xl p-6 backdrop-blur-md mb-8 flex flex-col md:flex-row gap-6 items-center justify-between">
<!-- Search bar -->
<div class="relative w-full md:max-w-md">
<span class="absolute inset-y-0 left-0 pl-4 flex items-center text-slate-500 pointer-events-none">
<i data-lucide="search" class="w-4 h-4"></i>
</span>
<input type="text" id="search-input" name="search-input" placeholder="Buscar parceiro ou benefício..." class="w-full bg-slate-950/60 border border-slate-800 rounded-xl pl-11 pr-4 py-3 text-sm focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/20 text-slate-100 placeholder:text-slate-500 transition-all duration-300">
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-2 justify-center" id="category-filters">
<button class="filter-pill active" data-category="all">Todos</button>
<button class="filter-pill" data-category="saude">🩺 Saúde</button>
<button class="filter-pill" data-category="odontologia">🦷 Odonto</button>
<button class="filter-pill" data-category="gastronomia">🍔 Gastronomia</button>
<button class="filter-pill" data-category="lazer">🌴 Lazer</button>
<button class="filter-pill" data-category="farmacia">💊 Farmácia</button>
<button class="filter-pill" data-category="educacao">📚 Educação</button>
</div>
</div>
<!-- Benefits Grid -->
<div id="benefits-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Loading State -->
<div class="col-span-full py-20 text-center" id="catalog-loading">
<div class="w-10 h-10 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p class="text-slate-400 text-sm">Carregando benefícios exclusivos...</p>
</div>
<!-- Dynamic cards will be injected here -->
</div>
</section>
<!-- SECTION 2: REDEEM -->
<section id="section-redeem" class="content-section hidden">
<div class="max-w-2xl mx-auto">
<div class="text-center mb-10">
<h2 class="font-display font-black text-3xl text-white tracking-tight mb-3">Validação & Resgate de Código</h2>
<p class="text-slate-400 text-sm leading-relaxed">
Selecione um benefício no catálogo para resgatar. Abaixo você pode visualizar e simular o fluxo de validação segura por Token QR Code.
</p>
</div>
<!-- Active Voucher Container -->
<div class="bg-slate-900/35 border border-slate-800/80 rounded-3xl p-8 backdrop-blur-xl relative overflow-hidden shadow-2xl" id="redeem-card-empty">
<div class="text-center py-12 text-slate-500">
<i data-lucide="tag" class="w-12 h-12 mx-auto mb-4 text-slate-600 animate-bounce"></i>
<h3 class="font-display font-bold text-white text-base mb-1">Nenhum cupom ativo no momento</h3>
<p class="text-xs max-w-sm mx-auto leading-relaxed">Navegue pelo Catálogo de Benefícios e selecione a oferta desejada clicando em "Resgatar Voucher".</p>
</div>
</div>
<div class="bg-slate-900/35 border border-slate-800/80 rounded-3xl p-8 backdrop-blur-xl relative overflow-hidden shadow-2xl hidden" id="redeem-card-active">
<div class="absolute top-0 right-0 w-40 h-40 bg-indigo-500/5 rounded-full blur-3xl pointer-events-none"></div>
<div class="flex items-center gap-4 mb-8 pb-6 border-b border-slate-800/80">
<div class="w-12 h-12 rounded-xl bg-indigo-600/10 border border-indigo-500/20 flex items-center justify-center text-xl" id="voucher-icon">🩺</div>
<div>
<h3 class="font-display font-black text-lg text-white leading-snug" id="voucher-title">Clube Odonto Plus</h3>
<p class="text-xs text-slate-400" id="voucher-partner">ScoreOdonto Ltda</p>
</div>
</div>
<!-- QR Code & Laser Scanner -->
<div class="flex flex-col items-center justify-center mb-8">
<div class="relative w-48 h-48 bg-white p-4 rounded-2xl shadow-xl shadow-indigo-600/5 border border-slate-700/20 overflow-hidden">
<!-- Simulated QR Code Image -->
<img src="https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=C67-A9X2B&color=0f172a" id="qr-code-img" class="w-full h-full object-contain" alt="QR Code">
<!-- Laser scan bar -->
<div class="absolute left-0 right-0 h-1 bg-indigo-500 shadow-lg shadow-indigo-500/80 animate-scan"></div>
</div>
<!-- Token Display -->
<div class="mt-5 px-6 py-2 bg-slate-950/80 border border-slate-800 rounded-xl">
<span class="text-xs font-bold tracking-[0.25em] text-indigo-400 uppercase font-mono" id="voucher-token">C67-A9X2B</span>
</div>
</div>
<!-- Timer Countdown -->
<div class="space-y-3 mb-8">
<div class="flex items-center justify-between text-xs font-bold text-slate-400">
<span>Token expira em:</span>
<span class="text-indigo-400 font-mono" id="timer-text">05:00</span>
</div>
<div class="h-1.5 w-full bg-slate-950 rounded-full overflow-hidden">
<div class="h-full bg-gradient-to-r from-indigo-500 to-violet-500 transition-all duration-1000" id="timer-bar" style="width: 100%"></div>
</div>
</div>
<!-- Simulator Trigger -->
<div class="flex flex-col gap-3">
<button id="btn-validate" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-display font-bold py-4 rounded-xl text-xs uppercase tracking-widest shadow-lg shadow-button transition-all duration-300">
Simular Validação (Estabelecimento)
</button>
<button id="btn-cancel" class="w-full bg-slate-950 hover:bg-slate-900 border border-slate-800 hover:border-slate-700 text-slate-400 hover:text-white font-display font-bold py-4 rounded-xl text-xs uppercase tracking-widest transition-all duration-300">
Cancelar Resgate
</button>
</div>
</div>
</div>
</section>
<!-- SECTION 3: ANALYTICS -->
<section id="section-analytics" class="content-section hidden">
<div class="mb-10 text-center md:text-left">
<h2 class="font-display font-black text-3xl text-white tracking-tight mb-3">Painel de Performance do Parceiro</h2>
<p class="text-slate-400 text-sm max-w-2xl leading-relaxed">
Acompanhe em tempo real a tração da sua marca, a economia estimada gerada para os membros do Clube67 e os picos de resgate semanais.
</p>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Stat 1 -->
<div class="bg-slate-900/40 border border-slate-800/80 rounded-2xl p-6 backdrop-blur-md relative overflow-hidden hover:border-indigo-500/30 transition-all duration-300">
<div class="absolute -top-6 -right-6 w-24 h-24 bg-indigo-500/5 rounded-full blur-2xl pointer-events-none"></div>
<div class="flex items-center justify-between mb-4">
<span class="text-xs font-bold text-slate-400 uppercase tracking-wider">Membros Ativos</span>
<div class="w-8 h-8 rounded-lg bg-indigo-600/10 flex items-center justify-center text-indigo-400">
<i data-lucide="users" class="w-4 h-4"></i>
</div>
</div>
<h3 class="font-display font-black text-3xl text-white mb-2" id="stat-members">1,248</h3>
<span class="text-10px font-bold text-emerald-400 flex items-center gap-1">
<i data-lucide="trending-up" class="w-3 h-3"></i> +12.4% este mês
</span>
</div>
<!-- Stat 2 -->
<div class="bg-slate-900/40 border border-slate-800/80 rounded-2xl p-6 backdrop-blur-md relative overflow-hidden hover:border-indigo-500/30 transition-all duration-300">
<div class="absolute -top-6 -right-6 w-24 h-24 bg-indigo-500/5 rounded-full blur-2xl pointer-events-none"></div>
<div class="flex items-center justify-between mb-4">
<span class="text-xs font-bold text-slate-400 uppercase tracking-wider">Resgates (Mês)</span>
<div class="w-8 h-8 rounded-lg bg-indigo-600/10 flex items-center justify-center text-indigo-400">
<i data-lucide="ticket" class="w-4 h-4"></i>
</div>
</div>
<h3 class="font-display font-black text-3xl text-white mb-2" id="stat-redemptions">384</h3>
<span class="text-10px font-bold text-emerald-400 flex items-center gap-1">
<i data-lucide="trending-up" class="w-3 h-3"></i> +8.2% esta semana
</span>
</div>
<!-- Stat 3 -->
<div class="bg-slate-900/40 border border-slate-800/80 rounded-2xl p-6 backdrop-blur-md relative overflow-hidden hover:border-indigo-500/30 transition-all duration-300">
<div class="absolute -top-6 -right-6 w-24 h-24 bg-indigo-500/5 rounded-full blur-2xl pointer-events-none"></div>
<div class="flex items-center justify-between mb-4">
<span class="text-xs font-bold text-slate-400 uppercase tracking-wider">Economia Gerada</span>
<div class="w-8 h-8 rounded-lg bg-indigo-600/10 flex items-center justify-center text-indigo-400">
<i data-lucide="piggy-bank" class="w-4 h-4"></i>
</div>
</div>
<h3 class="font-display font-black text-3xl text-white mb-2" id="stat-savings">R$ 14,820</h3>
<span class="text-10px font-bold text-emerald-400 flex items-center gap-1">
<i data-lucide="trending-up" class="w-3 h-3"></i> +15.1% de economia
</span>
</div>
</div>
<!-- Charts Grid -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- Chart 1: SVG Radial Goal (Left) -->
<div class="bg-slate-900/40 border border-slate-800/80 rounded-2xl p-6 backdrop-blur-md lg:col-span-4 flex flex-col items-center justify-between min-h-[350px]">
<div class="w-full">
<h4 class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-1">Atingimento de Meta</h4>
<p class="text-10px text-slate-500">Resgates cumulativos em relação ao objetivo mensal</p>
</div>
<!-- SVG Circular gauge -->
<div class="relative w-40 h-40 flex items-center justify-center my-6">
<svg class="w-full h-full transform -rotate-90">
<circle cx="80" cy="80" r="64" class="stroke-slate-950 fill-transparent" stroke-width="10" />
<circle cx="80" cy="80" r="64" id="radial-progress-bar" class="stroke-indigo-500 fill-transparent transition-all duration-1000" stroke-width="10" stroke-dasharray="402" stroke-dashoffset="402" stroke-linecap="round" />
</svg>
<div class="absolute flex flex-col items-center justify-center">
<span class="font-display font-black text-3xl text-white" id="radial-percentage">0%</span>
<span class="text-9px font-bold uppercase tracking-wider text-slate-500">Completado</span>
</div>
</div>
<div class="text-center w-full">
<span class="text-xs font-semibold text-slate-300">Faltam apenas <span class="text-indigo-400">16 resgates</span> para bater o recorde!</span>
</div>
</div>
<!-- Chart 2: Weekday vertical bar chart (Right) -->
<div class="bg-slate-900/40 border border-slate-800/80 rounded-2xl p-6 backdrop-blur-md lg:col-span-8 flex flex-col justify-between min-h-[350px]">
<div>
<h4 class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-1">Fluxo de Resgates por Dia</h4>
<p class="text-10px text-slate-500">Análise de picos semanais de resgates de cupons no estabelecimento</p>
</div>
<!-- Vertical bars container -->
<div class="flex items-end justify-between h-40 px-4 my-6 gap-3">
<!-- Monday -->
<div class="flex-1 flex flex-col items-center gap-2 group">
<span class="text-9px font-bold font-mono text-slate-500 group-hover:text-indigo-400 transition-colors opacity-0 group-hover:opacity-100 transition-opacity">12</span>
<div class="w-full bg-slate-950/80 rounded-t-lg h-40 flex items-end overflow-hidden border border-slate-900/20">
<div class="bg-gradient-to-t from-indigo-600 to-violet-500 w-full rounded-t-lg transition-all duration-1000" style="height: 0%" id="bar-seg"></div>
</div>
<span class="text-10px font-semibold text-slate-400">Seg</span>
</div>
<!-- Tuesday -->
<div class="flex-1 flex flex-col items-center gap-2 group">
<span class="text-9px font-bold font-mono text-slate-500 group-hover:text-indigo-400 transition-colors opacity-0 group-hover:opacity-100 transition-opacity">25</span>
<div class="w-full bg-slate-950/80 rounded-t-lg h-40 flex items-end overflow-hidden border border-slate-900/20">
<div class="bg-gradient-to-t from-indigo-600 to-violet-500 w-full rounded-t-lg transition-all duration-1000" style="height: 0%" id="bar-ter"></div>
</div>
<span class="text-10px font-semibold text-slate-400">Ter</span>
</div>
<!-- Wednesday -->
<div class="flex-1 flex flex-col items-center gap-2 group">
<span class="text-9px font-bold font-mono text-slate-500 group-hover:text-indigo-400 transition-colors opacity-0 group-hover:opacity-100 transition-opacity">42</span>
<div class="w-full bg-slate-950/80 rounded-t-lg h-40 flex items-end overflow-hidden border border-slate-900/20">
<div class="bg-gradient-to-t from-indigo-600 to-violet-500 w-full rounded-t-lg transition-all duration-1000" style="height: 0%" id="bar-qua"></div>
</div>
<span class="text-10px font-semibold text-slate-400">Qua</span>
</div>
<!-- Thursday -->
<div class="flex-1 flex flex-col items-center gap-2 group">
<span class="text-9px font-bold font-mono text-slate-500 group-hover:text-indigo-400 transition-colors opacity-0 group-hover:opacity-100 transition-opacity">30</span>
<div class="w-full bg-slate-950/80 rounded-t-lg h-40 flex items-end overflow-hidden border border-slate-900/20">
<div class="bg-gradient-to-t from-indigo-600 to-violet-500 w-full rounded-t-lg transition-all duration-1000" style="height: 0%" id="bar-qui"></div>
</div>
<span class="text-10px font-semibold text-slate-400">Qui</span>
</div>
<!-- Friday -->
<div class="flex-1 flex flex-col items-center gap-2 group">
<span class="text-9px font-bold font-mono text-slate-500 group-hover:text-indigo-400 transition-colors opacity-0 group-hover:opacity-100 transition-opacity">58</span>
<div class="w-full bg-slate-950/80 rounded-t-lg h-40 flex items-end overflow-hidden border border-slate-900/20">
<div class="bg-gradient-to-t from-indigo-600 to-violet-500 w-full rounded-t-lg transition-all duration-1000" style="height: 0%" id="bar-sex"></div>
</div>
<span class="text-10px font-semibold text-slate-400">Sex</span>
</div>
<!-- Saturday -->
<div class="flex-1 flex flex-col items-center gap-2 group">
<span class="text-9px font-bold font-mono text-slate-500 group-hover:text-indigo-400 transition-colors opacity-0 group-hover:opacity-100 transition-opacity">70</span>
<div class="w-full bg-slate-950/80 rounded-t-lg h-40 flex items-end overflow-hidden border border-slate-900/20">
<div class="bg-gradient-to-t from-indigo-600 to-violet-500 w-full rounded-t-lg transition-all duration-1000" style="height: 0%" id="bar-sab"></div>
</div>
<span class="text-10px font-semibold text-slate-400">Sáb</span>
</div>
<!-- Sunday -->
<div class="flex-1 flex flex-col items-center gap-2 group">
<span class="text-9px font-bold font-mono text-slate-500 group-hover:text-indigo-400 transition-colors opacity-0 group-hover:opacity-100 transition-opacity">18</span>
<div class="w-full bg-slate-950/80 rounded-t-lg h-40 flex items-end overflow-hidden border border-slate-900/20">
<div class="bg-gradient-to-t from-indigo-600 to-violet-500 w-full rounded-t-lg transition-all duration-1000" style="height: 0%" id="bar-dom"></div>
</div>
<span class="text-10px font-semibold text-slate-400">Dom</span>
</div>
</div>
<div class="flex justify-between items-center text-xs text-slate-400">
<span>Pico de atividades: <span class="text-white font-bold">Sábado</span></span>
<span>Atualizado há 5m</span>
</div>
</div>
</div>
</section>
</main>
<!-- Footer -->
<footer class="border-t border-slate-900/80 py-8 px-6 text-center text-slate-600 text-[10px] font-bold uppercase tracking-widest">
&copy; 2026 Clube67. Todos os direitos reservados.
</footer>
<!-- Script -->
<script src="script.js"></script>
</body>
</html>
+135
View File
@@ -0,0 +1,135 @@
import Cookies from 'js-cookie';
const API_BASE = '/api';
interface FetchOptions extends RequestInit {
skipAuth?: boolean;
}
async function fetchApi(url: string, options: FetchOptions = {}) {
const { skipAuth, ...fetchOpts } = options;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(fetchOpts.headers as Record<string, string>),
};
if (!skipAuth) {
const token = Cookies.get('accessToken');
if (token) headers['Authorization'] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}${url}`, { ...fetchOpts, headers });
if (res.status === 401) {
const data = await res.json().catch(() => ({}));
if (data.code === 'TOKEN_EXPIRED') {
const refreshed = await refreshToken();
if (refreshed) {
headers['Authorization'] = `Bearer ${Cookies.get('accessToken')}`;
const retry = await fetch(`${API_BASE}${url}`, { ...fetchOpts, headers });
return retry.json();
}
}
if (typeof window !== 'undefined') {
Cookies.remove('accessToken');
Cookies.remove('refreshToken');
window.location.href = '/login';
}
throw new Error('Não autenticado');
}
if (!res.ok) {
const data = await res.json().catch(() => ({ error: 'Erro desconhecido' }));
throw new Error(data.error || 'Erro na requisição');
}
return res.json();
}
async function refreshToken(): Promise<boolean> {
try {
const rt = Cookies.get('refreshToken');
if (!rt) return false;
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: rt }),
});
if (!res.ok) return false;
const data = await res.json();
Cookies.set('accessToken', data.accessToken, { expires: 1 });
Cookies.set('refreshToken', data.refreshToken, { expires: 7 });
return true;
} catch { return false; }
}
export const api = {
// Auth
login: (email: string, password: string) =>
fetchApi('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }), skipAuth: true }),
register: (data: any) =>
fetchApi('/auth/register', { method: 'POST', body: JSON.stringify(data), skipAuth: true }),
googleAuth: (data: any) =>
fetchApi('/auth/google', { method: 'POST', body: JSON.stringify(data), skipAuth: true }),
me: () => fetchApi('/auth/me'),
logout: () => fetchApi('/auth/logout', { method: 'POST' }),
// Users
getUsers: (params?: string) => fetchApi(`/users${params ? `?${params}` : ''}`),
getUser: (id: number) => fetchApi(`/users/${id}`),
updateUser: (id: number, data: any) => fetchApi(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteUser: (id: number) => fetchApi(`/users/${id}`, { method: 'DELETE' }),
getUserStats: () => fetchApi('/users/me/stats'),
getFavorites: () => fetchApi('/users/me/favorites'),
addFavorite: (benefitId: number) => fetchApi('/users/me/favorites', { method: 'POST', body: JSON.stringify({ benefitId }) }),
removeFavorite: (benefitId: number) => fetchApi(`/users/me/favorites/${benefitId}`, { method: 'DELETE' }),
getHistory: () => fetchApi('/users/me/history'),
// Partners
getPartners: (params?: string) => fetchApi(`/partners${params ? `?${params}` : ''}`),
getPartner: (id: number) => fetchApi(`/partners/${id}`),
getPartnerBySlug: (slug: string) => fetchApi(`/partners/slug/${slug}`),
createPartner: (data: any) => fetchApi('/partners', { method: 'POST', body: JSON.stringify(data) }),
updatePartner: (id: number, data: any) => fetchApi(`/partners/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deletePartner: (id: number) => fetchApi(`/partners/${id}`, { method: 'DELETE' }),
getPartnerStats: (id: number) => fetchApi(`/partners/${id}/stats`),
// Benefits
getBenefits: (params?: string) => fetchApi(`/benefits${params ? `?${params}` : ''}`),
getBenefit: (id: number) => fetchApi(`/benefits/${id}`),
createBenefit: (data: any) => fetchApi('/benefits', { method: 'POST', body: JSON.stringify(data) }),
updateBenefit: (id: number, data: any) => fetchApi(`/benefits/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteBenefit: (id: number) => fetchApi(`/benefits/${id}`, { method: 'DELETE' }),
approveBenefit: (id: number) => fetchApi(`/benefits/${id}/approve`, { method: 'POST' }),
redeemBenefit: (id: number) => fetchApi(`/benefits/${id}/redeem`, { method: 'POST' }),
getCategories: () => fetchApi('/benefits/categories'),
// Admin
getDashboard: () => fetchApi('/admin/dashboard'),
getAuditLogs: (params?: string) => fetchApi(`/admin/logs${params ? `?${params}` : ''}`),
getMetrics: (period?: string) => fetchApi(`/admin/metrics${period ? `?period=${period}` : ''}`),
// Plugins
getPlugins: () => fetchApi('/plugins'),
activatePlugin: (slug: string) => fetchApi(`/plugins/${slug}/activate`, { method: 'POST' }),
deactivatePlugin: (slug: string) => fetchApi(`/plugins/${slug}/deactivate`, { method: 'POST' }),
removePlugin: (slug: string) => fetchApi(`/plugins/${slug}`, { method: 'DELETE' }),
// Webhooks
getWebhooks: () => fetchApi('/webhooks'),
createWebhook: (data: any) => fetchApi('/webhooks', { method: 'POST', body: JSON.stringify(data) }),
};
export function setTokens(accessToken: string, refreshToken: string) {
Cookies.set('accessToken', accessToken, { expires: 1 });
Cookies.set('refreshToken', refreshToken, { expires: 7 });
}
export function clearTokens() {
Cookies.remove('accessToken');
Cookies.remove('refreshToken');
}
export function getAccessToken() {
return Cookies.get('accessToken');
}
+101
View File
@@ -0,0 +1,101 @@
'use client';
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { api, setTokens, clearTokens, getAccessToken } from './api';
import { useRouter } from 'next/navigation';
interface User {
id: number;
name: string;
email: string;
role: string;
avatar_url?: string;
phone?: string;
status: string;
}
interface AuthContextType {
user: User | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (data: any) => Promise<void>;
googleLogin: (data: any) => Promise<void>;
logout: () => void;
isAdmin: boolean;
isPartner: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const router = useRouter();
const fetchUser = useCallback(async () => {
try {
const token = getAccessToken();
if (!token) { setLoading(false); return; }
const data = await api.me();
setUser(data.user);
} catch {
clearTokens();
setUser(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchUser(); }, [fetchUser]);
const login = async (email: string, password: string) => {
const data = await api.login(email, password);
setTokens(data.accessToken, data.refreshToken);
setUser(data.user);
redirectByRole(data.user.role);
};
const register = async (formData: any) => {
const data = await api.register(formData);
setTokens(data.accessToken, data.refreshToken);
setUser(data.user);
router.push('/dashboard');
};
const googleLogin = async (googleData: any) => {
const data = await api.googleAuth(googleData);
setTokens(data.accessToken, data.refreshToken);
setUser(data.user);
redirectByRole(data.user.role);
};
const logout = () => {
api.logout().catch(() => { });
clearTokens();
setUser(null);
router.push('/login');
};
const redirectByRole = (role: string) => {
switch (role) {
case 'super_admin': router.push('/admin'); break;
case 'partner_admin': router.push('/partner'); break;
default: router.push('/dashboard'); break;
}
};
const isAdmin = user?.role === 'super_admin';
const isPartner = user?.role === 'partner_admin';
return (
<AuthContext.Provider value={{ user, loading, login, register, googleLogin, logout, isAdmin, isPartner }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
+24
View File
@@ -0,0 +1,24 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'clube67.com' },
{ protocol: 'https', hostname: 'lh3.googleusercontent.com' },
],
},
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://clube67-backend:3001/api/:path*',
},
{
source: '/uploads/:path*',
destination: 'http://clube67-backend:3001/uploads/:path*',
},
];
},
};
module.exports = nextConfig;
+1904
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "clube67-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000",
"lint": "next lint"
},
"dependencies": {
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"framer-motion": "^11.5.0",
"recharts": "^2.12.0",
"lucide-react": "^0.447.0",
"qrcode.react": "^4.0.0",
"js-cookie": "^3.0.5",
"clsx": "^2.1.1"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/js-cookie": "^3.0.6",
"typescript": "^5.5.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
-393
View File
@@ -1,393 +0,0 @@
// ── CLUBE67 PREMIUM INTERACTIVE SCRIPT ──
document.addEventListener('DOMContentLoaded', () => {
// Initialize Lucide Icons
lucide.createIcons();
// ── STATE MANAGEMENT ──
let activeTab = 'catalog';
let benefits = [];
let activeVoucher = null;
let countdownInterval = null;
let timerTotalSeconds = 300; // 5 minutes
let timerRemainingSeconds = 300;
// Premium Mock Data in case backend returns empty/offline to guarantee flawless display
const mockBenefits = [
{ id: 1, title: "Consulta + Limpeza Geral", description: "Ganhe 50% de desconto na primeira consulta preventiva e profilaxia.", category: "odontologia", partner: "ScoreOdonto MS", icon: "🦷", rating: "4.9", discount: "50% OFF" },
{ id: 2, title: "Checkout Cardiológico Completo", description: "Desconto especial de 30% em exames de eletrocardiograma e ecocardiograma.", category: "saude", partner: "CardioClinic CG", icon: "🩺", rating: "4.8", discount: "30% OFF" },
{ id: 3, title: "Menu Degustação Premium (Casal)", description: "Compre um menu degustação de 5 etapas e ganhe uma garrafa de vinho importado.", category: "gastronomia", partner: "Le Jardin Bistrô", icon: "🍔", rating: "5.0", discount: "Vinho Cortesia" },
{ id: 4, title: "Day Use Completo no Eco Resort", description: "Acesso total às piscinas naturais, trilhas e almoço pantaneiro incluso com 25% OFF.", category: "lazer", partner: "Bonito Eco Resort", icon: "🌴", rating: "4.7", discount: "25% OFF" },
{ id: 5, title: "Fórmulas Manipuladas e Vitaminas", description: "Desconto exclusivo de 20% em qualquer fórmula médica manipulada.", category: "farmacia", partner: "Fórmula Ativa", icon: "💊", rating: "4.9", discount: "20% OFF" },
{ id: 6, title: "MBA Executivo & Especializações", description: "Isenção na taxa de matrícula e 35% de desconto nas mensalidades do curso.", category: "educacao", partner: "Unigran Capital", icon: "📚", rating: "4.6", discount: "35% OFF" }
];
// Analytics Dashboard Data
const analyticsData = {
members: 1248,
redemptions: 384,
savings: 14820,
goalPercentage: 96,
weeklyDistribution: {
seg: 25,
ter: 45,
qua: 70,
qui: 55,
sex: 85,
sab: 100,
dom: 35
}
};
// ── DOM ELEMENTS ──
const navTabs = {
catalog: document.getElementById('tab-catalog'),
redeem: document.getElementById('tab-redeem'),
analytics: document.getElementById('tab-analytics')
};
const sections = {
catalog: document.getElementById('section-catalog'),
redeem: document.getElementById('section-redeem'),
analytics: document.getElementById('section-analytics')
};
const searchInput = document.getElementById('search-input');
const categoryFilters = document.getElementById('category-filters');
const benefitsGrid = document.getElementById('benefits-grid');
// Redeem tab elements
const redeemCardEmpty = document.getElementById('redeem-card-empty');
const redeemCardActive = document.getElementById('redeem-card-active');
const voucherIcon = document.getElementById('voucher-icon');
const voucherTitle = document.getElementById('voucher-title');
const voucherPartner = document.getElementById('voucher-partner');
const voucherToken = document.getElementById('voucher-token');
const qrCodeImg = document.getElementById('qr-code-img');
const timerText = document.getElementById('timer-text');
const timerBar = document.getElementById('timer-bar');
const btnValidate = document.getElementById('btn-validate');
const btnCancel = document.getElementById('btn-cancel');
// Analytics elements
const statMembers = document.getElementById('stat-members');
const statRedemptions = document.getElementById('stat-redemptions');
const statSavings = document.getElementById('stat-savings');
const radialProgressBar = document.getElementById('radial-progress-bar');
const radialPercentage = document.getElementById('radial-percentage');
const barSeg = document.getElementById('bar-seg');
const barTer = document.getElementById('bar-ter');
const barQua = document.getElementById('bar-qua');
const barQui = document.getElementById('bar-qui');
const barSex = document.getElementById('bar-sex');
const barSab = document.getElementById('bar-sab');
const barDom = document.getElementById('bar-dom');
// ── NAVIGATION LOGIC ──
function switchTab(tabId) {
if (activeTab === tabId) return;
// Update tabs active state
Object.keys(navTabs).forEach(key => {
if (key === tabId) {
navTabs[key].classList.add('active');
} else {
navTabs[key].classList.remove('active');
}
});
// Hide old section, show new section
Object.keys(sections).forEach(key => {
if (key === tabId) {
sections[key].classList.remove('hidden');
} else {
sections[key].classList.add('hidden');
}
});
activeTab = tabId;
// Perform specific triggers per tab
if (tabId === 'analytics') {
loadAnalyticsCharts();
}
}
Object.keys(navTabs).forEach(key => {
navTabs[key].addEventListener('click', () => switchTab(key));
});
// ── BENEFIT FETCHING & RENDERING ──
async function loadBenefits() {
try {
// Sincronized backend API consume
const response = await fetch('/api/benefits');
if (response.ok) {
const data = await response.json();
benefits = data && data.length > 0 ? data : mockBenefits;
} else {
benefits = mockBenefits;
}
} catch (err) {
console.warn('[Clube67] API offline, running with premium fallback simulation.');
benefits = mockBenefits;
}
renderBenefits(benefits);
}
function renderBenefits(list) {
const loadingEl = document.getElementById('catalog-loading');
if (loadingEl) loadingEl.remove();
benefitsGrid.innerHTML = '';
if (list.length === 0) {
benefitsGrid.innerHTML = `
<div class="col-span-full py-16 text-center text-slate-500">
<i data-lucide="frown" class="w-10 h-10 mx-auto mb-3"></i>
<p class="text-sm">Nenhum benefício encontrado para esta categoria ou busca.</p>
</div>
`;
lucide.createIcons();
return;
}
list.forEach(benefit => {
const card = document.createElement('div');
card.className = 'benefit-card';
// Mouse moves 3D lighting effect
card.addEventListener('mousemove', e => {
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
card.style.setProperty('--mouse-x', `${x}px`);
card.style.setProperty('--mouse-y', `${y}px`);
});
card.innerHTML = `
<div class="absolute top-4 right-4 bg-indigo-500/10 border border-indigo-500/20 text-indigo-300 text-[10px] font-bold uppercase tracking-widest px-3 py-1 rounded-full">
${benefit.discount || 'Desconto'}
</div>
<div class="flex items-center gap-4 mb-5">
<div class="w-12 h-12 rounded-2xl bg-indigo-600/10 border border-indigo-500/10 flex items-center justify-center text-2xl shadow-inner">
${benefit.icon || '🎁'}
</div>
<div>
<span class="text-[9px] font-bold tracking-widest text-slate-500 uppercase">${benefit.partner || 'Estabelecimento'}</span>
<h4 class="font-display font-bold text-base text-white leading-snug line-clamp-1 mt-0.5">${benefit.title}</h4>
</div>
</div>
<p class="text-slate-400 text-xs leading-relaxed mb-6 min-h-[48px] line-clamp-3">${benefit.description}</p>
<div class="flex items-center justify-between border-t border-slate-800/80 pt-4">
<div class="flex items-center gap-1.5 text-xs font-semibold text-amber-400">
<i data-lucide="star" class="w-3.5 h-3.5 fill-amber-400"></i>
<span>${benefit.rating || '4.8'}</span>
</div>
<button class="btn-redeem-trigger btn-primary px-4 py-2 rounded-xl text-[10px] font-bold uppercase tracking-wider transition-all duration-300" data-id="${benefit.id}">
Resgatar Voucher
</button>
</div>
`;
benefitsGrid.appendChild(card);
});
// Add event listeners to voucher buttons
document.querySelectorAll('.btn-redeem-trigger').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.getAttribute('data-id'), 10);
const selected = benefits.find(b => b.id === id);
if (selected) {
triggerVoucherRedemption(selected);
}
});
});
lucide.createIcons();
}
// ── SEARCH & FILTERING ──
let activeCategory = 'all';
function filterBenefits() {
const query = searchInput.value.toLowerCase().trim();
const filtered = benefits.filter(b => {
const matchesCategory = activeCategory === 'all' || b.category === activeCategory;
const matchesSearch = b.title.toLowerCase().includes(query) ||
b.description.toLowerCase().includes(query) ||
(b.partner && b.partner.toLowerCase().includes(query));
return matchesCategory && matchesSearch;
});
renderBenefits(filtered);
}
searchInput.addEventListener('input', filterBenefits);
categoryFilters.querySelectorAll('.filter-pill').forEach(pill => {
pill.addEventListener('click', () => {
categoryFilters.querySelector('.filter-pill.active').classList.remove('active');
pill.classList.add('active');
activeCategory = pill.getAttribute('data-category');
filterBenefits();
});
});
// ── VOUCHER REDEMPTION & SCANNER TIMER ──
function triggerVoucherRedemption(benefit) {
activeVoucher = benefit;
// Populate DOM elements
voucherIcon.textContent = benefit.icon || '🎁';
voucherTitle.textContent = benefit.title;
voucherPartner.textContent = benefit.partner || 'Estabelecimento';
// Generate dynamic unique token
const randToken = 'C67-' + Math.random().toString(36).substring(2, 7).toUpperCase();
voucherToken.textContent = randToken;
// Update QR Code Source with parameters
qrCodeImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${randToken}&color=0f172a`;
// Switch to redeem container view
redeemCardEmpty.classList.add('hidden');
redeemCardActive.classList.remove('hidden');
// Switch view tab
switchTab('redeem');
// Start 5 minutes expiration timer
startVoucherTimer();
}
function startVoucherTimer() {
if (countdownInterval) clearInterval(countdownInterval);
timerRemainingSeconds = timerTotalSeconds;
updateTimerDisplay();
countdownInterval = setInterval(() => {
timerRemainingSeconds--;
updateTimerDisplay();
if (timerRemainingSeconds <= 0) {
handleVoucherExpiration();
}
}, 1000);
}
function updateTimerDisplay() {
const min = Math.floor(timerRemainingSeconds / 60);
const sec = timerRemainingSeconds % 60;
timerText.textContent = `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
// Progress bar percentage shrink
const pct = (timerRemainingSeconds / timerTotalSeconds) * 100;
timerBar.style.width = `${pct}%`;
// Danger colors for low time
if (timerRemainingSeconds < 60) {
timerBar.className = 'h-full bg-gradient-to-r from-red-500 to-rose-500 transition-all duration-1000';
timerText.className = 'text-red-400 font-mono';
} else {
timerBar.className = 'h-full bg-gradient-to-r from-indigo-500 to-violet-500 transition-all duration-1000';
timerText.className = 'text-indigo-400 font-mono';
}
}
function handleVoucherExpiration() {
clearInterval(countdownInterval);
alert('Este token de resgate expirou por limite de tempo de segurança. Por favor, gere um novo voucher.');
cancelVoucher();
}
function cancelVoucher() {
if (countdownInterval) clearInterval(countdownInterval);
activeVoucher = null;
redeemCardActive.classList.add('hidden');
redeemCardEmpty.classList.remove('hidden');
}
btnCancel.addEventListener('click', cancelVoucher);
// ── VOUCHER VALIDATION SIMULATOR (API CONSUME) ──
btnValidate.addEventListener('click', async () => {
if (!activeVoucher) return;
btnValidate.disabled = true;
btnValidate.textContent = 'VALIDANDO...';
try {
// Dynamic validation call to backend API
const response = await fetch('/api/benefits/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tokenId: voucherToken.textContent,
benefitId: activeVoucher.id
})
});
// Simulated UI validation popup for high wow-factor and offline resilience
setTimeout(() => {
alert(`✅ SUCESSO!
O Voucher "${activeVoucher.title}" foi validado com sucesso e consumido pelo parceiro!
Token: ${voucherToken.textContent}
Estabelecimento: ${activeVoucher.partner || 'Clube67'}`);
// Update analytics counters to reflect live usage!
analyticsData.redemptions++;
analyticsData.savings += 45; // average discount saving value
cancelVoucher();
btnValidate.disabled = false;
btnValidate.textContent = 'Simular Validação (Estabelecimento)';
// Automatically redirect to Analytics to see the metric update live!
switchTab('analytics');
}, 1200);
} catch (err) {
console.error('Validation API fail:', err);
btnValidate.disabled = false;
btnValidate.textContent = 'Simular Validação (Estabelecimento)';
}
});
// ── ANALYTICS CHARTS & COUNTERS ──
function loadAnalyticsCharts() {
// Load live dynamic counters
statMembers.textContent = analyticsData.members.toLocaleString();
statRedemptions.textContent = analyticsData.redemptions.toLocaleString();
statSavings.textContent = `R$ ${analyticsData.savings.toLocaleString('pt-BR')}`;
// Load SVG gauge radial chart progress
const radius = 64;
const circumference = 2 * Math.PI * radius; // 402.12
const pct = analyticsData.goalPercentage;
const offset = circumference - (pct / 100) * circumference;
radialProgressBar.style.strokeDasharray = `${circumference}`;
radialProgressBar.style.strokeDashoffset = `${offset}`;
radialPercentage.textContent = `${pct}%`;
// Load vertical bar charts heights
setTimeout(() => {
barSeg.style.height = `${analyticsData.weeklyDistribution.seg}%`;
barTer.style.height = `${analyticsData.weeklyDistribution.ter}%`;
barQua.style.height = `${analyticsData.weeklyDistribution.qua}%`;
barQui.style.height = `${analyticsData.weeklyDistribution.qui}%`;
barSex.style.height = `${analyticsData.weeklyDistribution.sex}%`;
barSab.style.height = `${analyticsData.weeklyDistribution.sab}%`;
barDom.style.height = `${analyticsData.weeklyDistribution.dom}%`;
}, 100);
}
// Initialize Catalog Fetching
loadBenefits();
});
-324
View File
@@ -1,324 +0,0 @@
/* ── DESIGN SYSTEM & STYLING — CLUBE67 PREMIUM ── */
/* Typography & Root variables */
:root {
--font-display: 'Outfit', 'Inter', sans-serif;
--font-sans: 'Inter', sans-serif;
/* Sleek harmonized dark palette */
--color-bg: 15 23 42; /* slate-900 equivalent */
--color-primary: 99 102 241; /* indigo-500 */
--color-primary-rgb: 99, 102, 241;
--color-accent: 139 92 246; /* violet-500 */
--color-accent-rgb: 139, 92, 246;
--glass-bg: rgba(15, 23, 42, 0.45);
--glass-border: rgba(255, 255, 255, 0.05);
--glass-border-hover: rgba(99, 102, 241, 0.25);
}
/* Base resets & styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: rgb(10, 15, 30);
color: #e2e8f0;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
.font-display {
font-family: var(--font-display);
}
/* Custom Webkit scrollbar for sleek premium look */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: rgb(10, 15, 30);
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(99, 102, 241, 0.3);
}
/* Typography elements styling */
h1, h2, h3, h4 {
font-family: var(--font-display);
color: #ffffff;
}
/* Header & Navigation styling */
.nav-tab {
background: transparent;
border: none;
color: #64748b; /* slate-500 */
cursor: pointer;
}
.nav-tab:hover {
color: #cbd5e1; /* slate-300 */
}
.nav-tab.active {
background-color: rgba(99, 102, 241, 0.15);
color: #818cf8; /* indigo-400 */
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.08);
}
/* Pills & Filter chips */
.filter-pill {
background: rgba(15, 23, 42, 0.6);
border: 1px border rgba(255, 255, 255, 0.04);
color: #94a3b8; /* slate-400 */
padding: 8px 18px;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid rgba(255, 255, 255, 0.04);
}
.filter-pill:hover {
background: rgba(99, 102, 241, 0.08);
border-color: rgba(99, 102, 241, 0.2);
color: #cbd5e1;
}
.filter-pill.active {
background: gradient-to-r from-indigo-600 to-violet-600;
background: linear-gradient(135deg, rgb(79, 70, 229), rgb(124, 58, 237));
border-color: rgba(99, 102, 241, 0.4);
color: #ffffff;
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.25);
}
/* Glassmorphism Benefit Cards styling */
.benefit-card {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: 24px;
padding: 24px;
backdrop-filter: blur(12px);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
position: relative;
}
.benefit-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(800px circle at var(--mouse-x, 0) var(--mouse-y, 0), rgba(255, 255, 255, 0.03), transparent 40%);
z-index: 1;
pointer-events: none;
}
.benefit-card:hover {
transform: translateY(-5px);
border-color: var(--glass-border-hover);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.3), 0 0 20px rgba(99, 102, 241, 0.05);
}
/* Laser scan animation for voucher validation page */
@keyframes scan {
0%, 100% {
top: 0%;
opacity: 0.8;
}
50% {
top: 98%;
opacity: 0.8;
}
}
.animate-scan {
animation: scan 3s ease-in-out infinite;
}
/* Spacing & Layout Utilities */
.p-1 { padding: 0.25rem !important; }
.p-4 { padding: 1rem !important; }
.p-6 { padding: 1.5rem !important; }
.p-8 { padding: 2rem !important; }
.px-4 { padding-left: 1rem !important; padding-right: 1rem !important; }
.px-5 { padding-left: 1.25rem !important; padding-right: 1.25rem !important; }
.px-6 { padding-left: 1.5rem !important; padding-right: 1.5rem !important; }
.py-2 { padding-top: 0.5rem !important; padding-bottom: 0.5rem !important; }
.py-2\.5 { padding-top: 0.625rem !important; padding-bottom: 0.625rem !important; }
.py-3 { padding-top: 0.75rem !important; padding-bottom: 0.75rem !important; }
.py-4 { padding-top: 1rem !important; padding-bottom: 1rem !important; }
.py-8 { padding-top: 2rem !important; padding-bottom: 2rem !important; }
.py-12 { padding-top: 3rem !important; padding-bottom: 3rem !important; }
.py-20 { padding-top: 5rem !important; padding-bottom: 5rem !important; }
.pl-4 { padding-left: 1rem !important; }
.pl-11 { padding-left: 2.75rem !important; }
.pr-4 { padding-right: 1rem !important; }
/* Display & Flexbox */
.flex { display: flex !important; }
.flex-col { flex-direction: column !important; }
.items-center { align-items: center !important; }
.justify-between { justify-content: space-between !important; }
.justify-center { justify-content: center !important; }
.flex-wrap { flex-wrap: wrap !important; }
.flex-1 { flex: 1 1 0% !important; }
/* Gap Utilities */
.gap-1 { gap: 0.25rem !important; }
.gap-2 { gap: 0.5rem !important; }
.gap-3 { gap: 0.75rem !important; }
.gap-4 { gap: 1rem !important; }
.gap-6 { gap: 1.5rem !important; }
/* Grid Utilities */
.grid { display: grid !important; }
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)) !important; }
@media (min-width: 768px) {
.md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
.md\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)) !important; }
.md\:flex-row { flex-direction: row !important; }
.md\:text-left { text-align: left !important; }
}
@media (min-width: 1024px) {
.lg\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)) !important; }
.lg\:grid-cols-12 { grid-template-columns: repeat(12, minmax(0, 1fr)) !important; }
.lg\:col-span-4 { grid-column: span 4 / span 4 !important; }
.lg\:col-span-8 { grid-column: span 8 / span 8 !important; }
}
/* Sizing Utilities */
.w-full { width: 100% !important; }
.max-w-7xl { max-width: 80rem !important; }
.max-w-2xl { max-width: 42rem !important; }
.max-w-md { max-width: 28rem !important; }
.mx-auto { margin-left: auto !important; margin-right: auto !important; }
.w-3\.5 { width: 0.875rem !important; }
.h-3\.5 { height: 0.875rem !important; }
.w-4 { width: 1rem !important; }
.h-4 { height: 1rem !important; }
.w-8 { width: 2rem !important; }
.h-8 { height: 2rem !important; }
.w-10 { width: 2.5rem !important; }
.h-10 { height: 2.5rem !important; }
.w-12 { width: 3rem !important; }
.h-12 { height: 3rem !important; }
.w-40 { width: 10rem !important; }
.h-40 { height: 10rem !important; }
.w-48 { width: 12rem !important; }
.h-48 { height: 12rem !important; }
.w-96 { width: 24rem !important; }
.h-96 { height: 24rem !important; }
.w-\[500px\] { width: 500px !important; }
.h-\[500px\] { height: 500px !important; }
/* Borders & Styles */
.border { border: 1px solid rgba(255, 255, 255, 0.08) !important; }
.border-b { border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important; }
.border-slate-800 { border-color: rgba(30, 41, 59, 0.6) !important; }
.border-slate-800\/50 { border-color: rgba(30, 41, 59, 0.4) !important; }
.border-slate-800\/80 { border-color: rgba(30, 41, 59, 0.8) !important; }
.border-slate-900\/80 { border-color: rgba(15, 23, 42, 0.8) !important; }
.border-slate-900\/20 { border-color: rgba(15, 23, 42, 0.2) !important; }
/* Border Radius */
.rounded-lg { border-radius: 0.5rem !important; }
.rounded-xl { border-radius: 0.75rem !important; }
.rounded-2xl { border-radius: 1rem !important; }
.rounded-3xl { border-radius: 1.5rem !important; }
/* Typography & Colors */
.text-center { text-align: center !important; }
.text-slate-400 { color: #94a3b8 !important; }
.text-slate-500 { color: #64748b !important; }
.text-slate-600 { color: #475569 !important; }
.text-slate-100 { color: #f1f5f9 !important; }
.text-white { color: #ffffff !important; }
.text-indigo-400 { color: #818cf8 !important; }
.text-emerald-400 { color: #34d399 !important; }
.font-bold { font-weight: 700 !important; }
.font-semibold { font-weight: 600 !important; }
.font-black { font-weight: 900 !important; }
.text-xs { font-size: 0.75rem !important; }
.text-sm { font-size: 0.875rem !important; }
.text-base { font-size: 1rem !important; }
.text-lg { font-size: 1.125rem !important; }
.text-xl { font-size: 1.25rem !important; }
.text-3xl { font-size: 1.875rem !important; }
.text-4xl { font-size: 2.25rem !important; }
.uppercase { text-transform: uppercase !important; }
.tracking-wide { letter-spacing: 0.025em !important; }
.tracking-wider { letter-spacing: 0.05em !important; }
.tracking-widest { letter-spacing: 0.1em !important; }
.leading-none { line-height: 1 !important; }
.leading-relaxed { line-height: 1.625 !important; }
.leading-snug { line-height: 1.375 !important; }
/* Transitions & Shadows */
.transition-all { transition-property: all !important; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important; transition-duration: 150ms !important; }
.duration-300 { transition-duration: 300ms !important; }
.shadow-lg { box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.05) !important; }
.shadow-2xl { box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5) !important; }
/* Semantic Classes */
.brand-subtitle {
font-size: 9px !important;
letter-spacing: 0.25em !important;
}
.trend-pct {
font-size: 10px !important;
}
.shadow-logo {
box-shadow: 0 10px 15px -3px rgba(99, 102, 241, 0.2), 0 4px 6px -2px rgba(99, 102, 241, 0.1) !important;
}
.shadow-button {
box-shadow: 0 10px 15px -3px rgba(99, 102, 241, 0.25), 0 4px 6px -2px rgba(99, 102, 241, 0.15) !important;
}
.text-9px { font-size: 9px !important; }
.text-10px { font-size: 10px !important; }
.letter-spacing-25 { letter-spacing: 0.25em !important; }
.selection\:bg-indigo-500\/30::selection { background-color: rgba(99, 102, 241, 0.3); }
.selection\:text-indigo-200::selection { color: #c7d2fe; }
.fixed { position: fixed !important; }
.top-0 { top: 0 !important; }
.sticky { position: sticky !important; }
.z-40 { z-index: 40 !important; }
.backdrop-blur-md { backdrop-filter: blur(12px) !important; -webkit-backdrop-filter: blur(12px) !important; }
.backdrop-blur-xl { backdrop-filter: blur(24px) !important; -webkit-backdrop-filter: blur(24px) !important; }
.pointer-events-none { pointer-events: none !important; }
.overflow-x-hidden { overflow-x: hidden !important; }
.relative { position: relative !important; }
.absolute { position: absolute !important; }
.inset-y-0 { top: 0; bottom: 0; }
.left-0 { left: 0 !important; }
.right-0 { right: 0 !important; }
.pl-4 { padding-left: 1rem !important; }
.object-contain { object-fit: contain !important; }
.bg-slate-950 { background-color: rgb(2, 6, 23) !important; }
.bg-slate-950\/60 { background-color: rgba(2, 6, 23, 0.6) !important; }
.bg-slate-950\/80 { background-color: rgba(2, 6, 23, 0.8) !important; }
.bg-slate-900\/40 { background-color: rgba(15, 23, 42, 0.4) !important; }
.bg-slate-900\/60 { background-color: rgba(15, 23, 42, 0.6) !important; }
.bg-slate-900\/35 { background-color: rgba(15, 23, 42, 0.35) !important; }
+68
View File
@@ -0,0 +1,68 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
brand: {
50: '#eef7ff',
100: '#d8ecff',
200: '#b9deff',
300: '#89caff',
400: '#52adff',
500: '#2a8bff',
600: '#1469f5',
700: '#0d54e1',
800: '#1244b6',
900: '#153d8f',
950: '#112757',
},
dark: {
50: '#f6f6f9',
100: '#ededf1',
200: '#d7d8e0',
300: '#b4b5c5',
400: '#8b8da5',
500: '#6d6f8a',
600: '#575972',
700: '#47495d',
800: '#3d3f4f',
900: '#1e1f2b',
950: '#13141d',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
},
animation: {
'fade-in': 'fadeIn 0.5s ease-out',
'slide-up': 'slideUp 0.5s ease-out',
'slide-in': 'slideIn 0.3s ease-out',
'pulse-soft': 'pulseSoft 2s infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(20px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
slideIn: {
'0%': { opacity: '0', transform: 'translateX(-20px)' },
'100%': { opacity: '1', transform: 'translateX(0)' },
},
pulseSoft: {
'0%, 100%': { opacity: '1' },
'50%': { opacity: '0.7' },
},
},
},
},
plugins: [],
};
+40
View File
@@ -0,0 +1,40 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}