feat: restaura o projeto original do clube67.com (Next.js + Express API + MySQL + Redis) livre de gambiarras e dockerizado
This commit is contained in:
@@ -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 só 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 só 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 já 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
Já 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
© 2026 Clube67. Todos os direitos reservados.
|
||||
</footer>
|
||||
|
||||
<!-- Script -->
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+5
@@ -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.
|
||||
@@ -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;
|
||||
Generated
+1904
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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; }
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user