first commit

This commit is contained in:
mstfyldz
2026-01-18 16:48:15 +03:00
parent 68ba54fa4f
commit af09543374
29 changed files with 2666 additions and 82 deletions

View File

@@ -0,0 +1,177 @@
import React from 'react';
import {
BarChart3,
TrendingUp,
ArrowUpRight,
ArrowDownRight,
Globe,
Monitor,
Smartphone,
Calendar
} from 'lucide-react';
import { supabaseAdmin } from '@/lib/supabase';
import { format, subDays } from 'date-fns';
import { tr } from 'date-fns/locale';
async function getAnalyticsData() {
const { data: transactions, error } = await supabaseAdmin
.from('transactions')
.select('*')
.order('created_at', { ascending: true });
if (error || !transactions) return null;
const successfulTransactions = transactions.filter(t => t.status === 'succeeded');
const totalRevenue = successfulTransactions.reduce((acc, t) => acc + Number(t.amount), 0);
const avgOrderValue = successfulTransactions.length > 0 ? totalRevenue / successfulTransactions.length : 0;
// Monthly data for chart (grouped by month or last 12 periods)
// To keep it simple and meaningful, let's show last 12 days for "Gelir Trendi"
const last12Periods = Array.from({ length: 12 }, (_, i) => {
const d = subDays(new Date(), 11 - i);
return {
date: d.toISOString().split('T')[0],
label: format(d, 'd MMM', { locale: tr }),
amount: 0
};
});
successfulTransactions.forEach(t => {
const dateStr = new Date(t.created_at).toISOString().split('T')[0];
const periodMatch = last12Periods.find(p => p.date === dateStr);
if (periodMatch) {
periodMatch.amount += Number(t.amount);
}
});
return {
totalRevenue,
avgOrderValue,
chartData: last12Periods,
totalCount: transactions.length,
successCount: successfulTransactions.length,
};
}
export default async function AnalyticsPage() {
const data = await getAnalyticsData();
if (!data) return <div className="p-10 font-black text-gray-400 uppercase tracking-[0.2em] animate-pulse">Veriler hazırlanıyor...</div>;
const metrics = [
{ label: 'Dönüşüm Oranı', value: '3.24%', trend: '+0.8%', positive: true }, // Mocked for now
{ label: 'Ort. Sipariş Tutarı', value: `${data.avgOrderValue.toLocaleString('tr-TR', { maximumFractionDigits: 2 })} ₺`, trend: '+12%', positive: true },
{ label: 'Başarılı İşlem', value: data.successCount.toString(), trend: '+24%', positive: true },
{ label: 'İşlem Başarısı', value: `${((data.successCount / (data.totalCount || 1)) * 100).toFixed(1)}%`, trend: '-0.2%', positive: false },
];
const maxChartAmount = Math.max(...data.chartData.map(d => d.amount), 100);
return (
<div className="space-y-10 animate-in fade-in slide-in-from-bottom-4 duration-700">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div>
<h1 className="text-3xl font-black text-gray-900 tracking-tight">Analizler</h1>
<p className="text-sm text-gray-400 font-bold uppercase tracking-widest mt-2">Sistem performans verileri</p>
</div>
<div className="flex items-center gap-4">
<button className="flex items-center gap-3 px-6 py-4 bg-white border border-gray-100 rounded-2xl text-xs font-black text-gray-600 hover:bg-gray-50 transition uppercase tracking-widest">
<Calendar size={18} className="text-gray-300" />
Son 30 Gün
</button>
</div>
</div>
{/* Main Metrics */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{metrics.map((item, i) => (
<div key={i} className="bg-white p-8 rounded-[32px] border border-gray-100 shadow-sm space-y-4">
<p className="text-[10px] text-gray-400 font-black uppercase tracking-[0.2em]">{item.label}</p>
<div className="flex items-end justify-between">
<h3 className="text-2xl font-black text-gray-900 leading-none">{item.value}</h3>
<div className={`flex items-center gap-1 text-[10px] font-black uppercase tracking-tighter ${item.positive ? 'text-emerald-500' : 'text-red-500'}`}>
{item.positive ? <ArrowUpRight size={14} /> : <ArrowDownRight size={14} />}
{item.trend}
</div>
</div>
</div>
))}
</div>
{/* Charts Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Performance Chart */}
<div className="bg-white p-10 rounded-[40px] border border-gray-100 shadow-sm space-y-10 text-sans tracking-tight">
<div className="flex justify-between items-center">
<h3 className="text-xl font-black text-gray-900 uppercase tracking-tight">Ciro Trendi (12 Gün)</h3>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-500"></div>
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Gerçekleşen</span>
</div>
</div>
</div>
<div className="h-72 flex items-end justify-between gap-4">
{data.chartData.map((d, i) => {
const h = (d.amount / maxChartAmount) * 90 + 5; // 5% to 95%
return (
<div key={i} className="flex-1 group relative h-full flex flex-col justify-end">
<div
className="w-full bg-blue-500 rounded-t-xl transition-all duration-500 group-hover:bg-blue-600 cursor-pointer relative"
style={{ height: `${h}%` }}
>
<div className="absolute -top-12 left-1/2 -translate-x-1/2 bg-gray-900 text-white text-[10px] font-black py-2 px-3 rounded-lg opacity-0 group-hover:opacity-100 transition shadow-xl pointer-events-none whitespace-nowrap z-20">
{d.amount.toLocaleString('tr-TR')}
</div>
</div>
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 text-[9px] font-black text-gray-300 uppercase tracking-tighter text-center">
{d.label}
</div>
</div>
);
})}
</div>
</div>
{/* Breakdown Grid */}
<div className="grid grid-cols-1 gap-8 text-sans tracking-tight">
<div className="bg-white p-10 rounded-[40px] border border-gray-100 shadow-sm">
<h3 className="text-xl font-black text-gray-900 uppercase tracking-tight mb-8">Cihaz Dağılımı</h3>
<div className="space-y-8">
{[
{ label: 'Mobil', value: '64%', icon: Smartphone, color: 'bg-blue-600', width: '64%' },
{ label: 'Masaüstü', value: '28%', icon: Monitor, color: 'bg-indigo-400', width: '28%' },
{ label: 'Tablet', value: '8%', icon: Globe, color: 'bg-indigo-100', width: '8%' },
].map((item, i) => (
<div key={i} className="space-y-3">
<div className="flex items-center justify-between text-xs font-black text-gray-900 uppercase tracking-widest">
<div className="flex items-center gap-3">
<item.icon size={18} className="text-gray-300" />
<span>{item.label}</span>
</div>
<span>{item.value}</span>
</div>
<div className="h-3 w-full bg-gray-50 rounded-full overflow-hidden border border-gray-100">
<div className={`h-full ${item.color} rounded-full`} style={{ width: item.width }}></div>
</div>
</div>
))}
</div>
</div>
<div className="bg-[#2563EB] p-10 rounded-[40px] shadow-2xl shadow-blue-200 text-white relative overflow-hidden group">
<div className="relative z-10 space-y-6">
<h3 className="text-2xl font-black leading-tight">Analizleriniz hazır! <br /> Bu ay başarılı bir grafik çiziyorsunuz.</h3>
<button className="px-8 py-4 bg-white text-blue-600 rounded-2xl font-black text-sm hover:scale-105 transition active:scale-95 shadow-xl uppercase tracking-widest">
Akıllı İpuçlarını
</button>
</div>
<BarChart3 className="absolute -bottom-10 -right-10 w-64 h-64 text-white/10 rotate-12 group-hover:rotate-0 transition-transform duration-700" />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,179 @@
import React from 'react';
import {
Users,
Search,
Plus,
Mail,
Phone,
MoreHorizontal,
ArrowUpRight
} from 'lucide-react';
import { supabaseAdmin } from '@/lib/supabase';
async function getCustomers() {
const { data: transactions, error } = await supabaseAdmin
.from('transactions')
.select('*');
if (error || !transactions) return null;
// Group transactions by name or phone
const customerMap = new Map();
transactions.forEach(t => {
const key = t.customer_name || t.customer_phone || 'Unknown';
if (!customerMap.has(key)) {
customerMap.set(key, {
id: t.id,
name: t.customer_name || 'İsimsiz Müşteri',
phone: t.customer_phone || 'Telefon Yok',
spent: 0,
orders: 0,
status: 'New'
});
}
const c = customerMap.get(key);
c.orders += 1;
if (t.status === 'succeeded') {
c.spent += Number(t.amount);
}
});
const customers = Array.from(customerMap.values()).map(c => {
if (c.orders > 5) c.status = 'High Value';
else if (c.orders > 1) c.status = 'Active';
return c;
});
return customers;
}
export default async function CustomersPage() {
const customers = await getCustomers();
if (!customers) return <div className="p-10 font-black text-gray-400 uppercase tracking-widest animate-pulse">Müşteriler yükleniyor...</div>;
return (
<div className="space-y-10 animate-in fade-in slide-in-from-bottom-4 duration-700">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div>
<h1 className="text-3xl font-black text-gray-900 tracking-tight">Müşteriler</h1>
<p className="text-sm text-gray-400 font-bold uppercase tracking-widest mt-2">Müşteri portföyünüzü yönetin</p>
</div>
<button className="flex items-center justify-center gap-3 px-8 py-4 bg-[#2563EB] text-white rounded-2xl font-black shadow-xl shadow-blue-100 hover:bg-blue-700 transition active:scale-95 uppercase text-xs tracking-widest">
<Plus size={18} />
Yeni Müşteri Ekle
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="bg-white p-10 rounded-[40px] border border-gray-100 shadow-sm flex items-center gap-8">
<div className="w-16 h-16 bg-blue-50 rounded-[20px] flex items-center justify-center text-blue-600">
<Users size={32} />
</div>
<div>
<p className="text-3xl font-black text-gray-900">{customers.length.toLocaleString('tr-TR')}</p>
<p className="text-[10px] text-gray-400 font-black uppercase tracking-widest mt-1">Toplam Müşteri</p>
</div>
</div>
<div className="bg-white p-10 rounded-[40px] border border-gray-100 shadow-sm flex items-center gap-8">
<div className="w-16 h-16 bg-emerald-50 rounded-[20px] flex items-center justify-center text-emerald-600">
<ArrowUpRight size={32} />
</div>
<div>
<p className="text-3xl font-black text-gray-900">Gerçek</p>
<p className="text-[10px] text-gray-400 font-black uppercase tracking-widest mt-1">Canlı Veri</p>
</div>
</div>
<div className="bg-white p-10 rounded-[40px] border border-gray-100 shadow-sm flex items-center gap-8">
<div className="w-16 h-16 bg-orange-50 rounded-[20px] flex items-center justify-center text-orange-600">
<Phone size={32} />
</div>
<div>
<p className="text-3xl font-black text-gray-900">{customers.filter(c => c.phone !== 'Telefon Yok').length}</p>
<p className="text-[10px] text-gray-400 font-black uppercase tracking-widest mt-1">Telefon Kayıtlı</p>
</div>
</div>
</div>
{/* List */}
<div className="bg-white rounded-[40px] border border-gray-100 shadow-sm overflow-hidden">
<div className="p-8 border-b border-gray-50 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-6 top-1/2 -translate-y-1/2 text-gray-300" size={20} />
<input
type="text"
placeholder="İsim veya telefon ile ara..."
className="w-full pl-16 pr-6 py-5 bg-gray-50 border-none rounded-2xl text-sm font-medium focus:ring-2 focus:ring-blue-500 outline-none placeholder:text-gray-300"
/>
</div>
<button className="text-blue-600 text-xs font-black uppercase tracking-widest hover:underline decoration-2 underline-offset-4 px-6 py-4">
Görünümü Filtrele
</button>
</div>
<div className="overflow-x-auto text-sans tracking-tight">
<table className="w-full text-left">
<thead>
<tr className="bg-gray-50/30 text-gray-400 text-[10px] font-black uppercase tracking-[0.2em] border-b border-gray-50">
<th className="px-10 py-8">Müşteri Bilgileri</th>
<th className="px-10 py-8">Segment</th>
<th className="px-10 py-8">Sipariş</th>
<th className="px-10 py-8">Toplam Harcama</th>
<th className="px-10 py-8 text-right">Aksiyonlar</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{customers.map((customer, i) => (
<tr key={i} className="group hover:bg-gray-50/50 transition-colors">
<td className="px-10 py-10">
<div className="flex items-center gap-5">
<div className="w-14 h-14 bg-gray-100 rounded-2xl flex items-center justify-center text-gray-400 font-black text-sm uppercase tracking-tighter">
{customer.name.slice(0, 2).toUpperCase()}
</div>
<div className="flex flex-col">
<span className="text-sm font-black text-gray-900 uppercase tracking-tight">{customer.name}</span>
<span className="text-[10px] text-gray-400 font-bold mt-1 tracking-widest">{customer.phone}</span>
</div>
</div>
</td>
<td className="px-10 py-10">
<span className={`inline-flex items-center px-4 py-1.5 rounded-full text-[10px] font-black uppercase tracking-widest ${customer.status === 'Active' ? 'bg-emerald-50 text-emerald-600' :
customer.status === 'High Value' ? 'bg-blue-50 text-blue-600' :
customer.status === 'New' ? 'bg-indigo-50 text-indigo-600' :
'bg-gray-50 text-gray-400'
}`}>
{customer.status === 'Active' ? 'Aktif' :
customer.status === 'High Value' ? 'VIP' :
customer.status === 'New' ? 'Yeni Üye' : 'İnaktif'}
</span>
</td>
<td className="px-10 py-10">
<span className="text-sm font-black text-gray-900">{customer.orders}</span>
</td>
<td className="px-10 py-10">
<span className="text-sm font-black text-gray-900">
{customer.spent.toLocaleString('tr-TR', { minimumFractionDigits: 2 })}
</span>
</td>
<td className="px-10 py-10 text-right">
<div className="flex items-center justify-end gap-3">
<button className="p-3 bg-gray-50 text-gray-400 rounded-xl hover:text-blue-600 hover:bg-blue-50 transition">
<Phone size={18} />
</button>
<button className="p-3 bg-gray-50 text-gray-400 rounded-xl hover:text-gray-900 hover:bg-gray-100 transition">
<MoreHorizontal size={18} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

137
app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,137 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import {
LayoutDashboard,
CreditCard,
Users,
BarChart3,
Settings,
LogOut,
Search,
Bell,
MessageSquare,
ChevronDown,
Wallet
} from 'lucide-react';
import Image from 'next/image';
import { createClient } from '@/utils/supabase/client'; // Assuming a client-side Supabase client utility
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const supabase = createClient();
const handleSignOut = async () => {
await supabase.auth.signOut();
router.push('/login');
router.refresh();
};
const navItems = [
{ label: 'Genel Bakış', icon: LayoutDashboard, href: '/admin' },
{ label: 'İşlemler', icon: CreditCard, href: '/admin/transactions' },
{ label: 'Müşteriler', icon: Users, href: '/admin/customers' },
{ label: 'Analizler', icon: BarChart3, href: '/admin/analytics' },
{ label: 'Ayarlar', icon: Settings, href: '/admin/settings' },
];
return (
<div className="flex h-screen bg-[#F8FAFC]">
{/* Sidebar */}
<aside className="w-72 bg-white border-r border-gray-100 flex flex-col shrink-0">
<div className="p-8 flex items-center gap-4">
<div className="w-12 h-12 bg-[#2563EB] rounded-xl flex items-center justify-center text-white shadow-lg shadow-blue-100">
<Wallet size={24} />
</div>
<div>
<h1 className="font-black text-gray-900 leading-tight">froyd Admin</h1>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Yönetim Paneli</p>
</div>
</div>
<nav className="flex-1 px-4 mt-4 space-y-2">
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.label}
href={item.label === 'Müşteriler' || item.label === 'Analizler' || item.label === 'Ayarlar' ? (item.href === pathname ? item.href : item.href) : item.href}
className={`flex items-center gap-4 px-6 py-4 rounded-2xl text-sm font-bold transition-all duration-200 group ${isActive
? 'bg-blue-50 text-blue-600'
: 'text-gray-400 hover:text-gray-900 hover:bg-gray-50'
}`}
>
<item.icon size={22} className={isActive ? 'text-blue-600' : 'text-gray-300 group-hover:text-gray-500'} />
{item.label}
</Link>
);
})}
</nav>
<div className="p-6 border-t border-gray-50">
<button
onClick={handleSignOut}
className="flex items-center gap-4 px-6 py-4 text-sm font-bold text-gray-400 hover:text-red-500 hover:bg-red-50 w-full rounded-2xl transition-all duration-200 group"
>
<LogOut size={22} className="text-gray-300 group-hover:text-red-500" />
Çıkış Yap
</button>
</div>
</aside>
{/* Main Container */}
<div className="flex-1 flex flex-col min-w-0">
{/* Top Bar */}
<header className="h-24 bg-white border-b border-gray-100 flex items-center justify-between px-10 gap-8">
<div className="flex-1 flex items-center">
<h2 className="text-xl font-black text-gray-900 mr-8 shrink-0">Yönetim Paneli</h2>
<div className="relative max-w-md w-full">
<Search className="absolute left-5 top-1/2 -translate-y-1/2 text-gray-300" size={20} />
<input
type="text"
placeholder="İşlemlerde ara..."
className="w-full pl-14 pr-6 py-4 bg-gray-50 border-none rounded-2xl text-sm font-medium focus:ring-2 focus:ring-blue-500 outline-none placeholder:text-gray-300"
/>
</div>
</div>
<div className="flex items-center gap-8">
<div className="flex items-center gap-4">
<button className="relative p-2 text-gray-400 hover:text-blue-600 transition">
<Bell size={24} />
<span className="absolute top-2 right-2 w-2 h-2 bg-blue-600 rounded-full border-2 border-white"></span>
</button>
<button className="p-2 text-gray-400 hover:text-blue-600 transition">
<MessageSquare size={24} />
</button>
</div>
<div className="flex items-center gap-4 pl-8 border-l border-gray-100">
<div className="text-right">
<p className="text-sm font-black text-gray-900 leading-none">Admin</p>
<p className="text-[10px] text-blue-600 font-bold uppercase tracking-wider mt-1">Süper Admin</p>
</div>
<div className="relative w-12 h-12 rounded-2xl bg-orange-100 overflow-hidden ring-4 ring-orange-50">
<div className="absolute inset-0 flex items-center justify-center text-orange-500 font-bold font-mono">AD</div>
{/* Fallback avatar if needed */}
</div>
<ChevronDown size={18} className="text-gray-300" />
</div>
</div>
</header>
{/* Content Area */}
<main className="flex-1 overflow-y-auto p-10">
{children}
</main>
</div>
</div>
);
}

327
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,327 @@
import React from 'react';
import { supabaseAdmin } from '@/lib/supabase';
import {
TrendingUp,
TrendingDown,
Users,
Wallet,
ClipboardList,
CheckCircle2,
} from 'lucide-react';
import { format } from 'date-fns';
import { tr } from 'date-fns/locale';
import Link from 'next/link';
async function getStats() {
const { data: transactions, error } = await supabaseAdmin
.from('transactions')
.select('*')
.order('created_at', { ascending: false });
if (error || !transactions) return null;
const successfulTransactions = transactions.filter(t => t.status === 'succeeded');
const totalRevenue = successfulTransactions.reduce((acc, t) => acc + Number(t.amount), 0);
const successfulCount = successfulTransactions.length;
const pendingCount = transactions.filter(t => t.status === 'pending').length;
const totalCount = transactions.length;
const successRate = totalCount > 0 ? (successfulCount / totalCount) * 100 : 0;
// Calculate unique customers
const uniqueCustomers = new Set(
transactions
.filter(t => t.customer_name || t.customer_phone)
.map(t => t.customer_name || t.customer_phone)
).size;
// Last 30 days chart data
const last30Days = Array.from({ length: 30 }, (_, i) => {
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() - (29 - i));
return {
date: d.toISOString().split('T')[0],
displayDate: format(d, 'd MMM', { locale: tr }),
amount: 0
};
});
successfulTransactions.forEach(t => {
const dateStr = new Date(t.created_at).toISOString().split('T')[0];
const dayMatch = last30Days.find(d => d.date === dateStr);
if (dayMatch) {
dayMatch.amount += Number(t.amount);
}
});
return {
transactions,
totalRevenue,
successfulCount,
pendingCount,
successRate,
totalCount,
uniqueCustomers,
chartData: last30Days
};
}
export default async function AdminDashboard() {
const stats = await getStats();
if (!stats) {
return <div className="p-10 font-bold text-gray-500 font-sans tracking-tight uppercase">Henüz bir işlem verisi bulunamadı.</div>;
}
const recentTransactions = stats.transactions.slice(0, 5);
return (
<div className="space-y-10 animate-in fade-in slide-in-from-bottom-4 duration-700">
{/* Top Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{/* Total Revenue */}
<div className="bg-white p-8 rounded-3xl border border-gray-100 shadow-sm space-y-4">
<div className="flex justify-between items-start">
<p className="text-sm font-bold text-gray-400 uppercase tracking-wider">Toplam Ciro</p>
<div className="p-3 bg-blue-50 rounded-xl text-blue-600">
<Wallet size={20} />
</div>
</div>
<div>
<h3 className="text-3xl font-black text-gray-900">{stats.totalRevenue.toLocaleString('tr-TR', { minimumFractionDigits: 2 })} </h3>
<div className="flex items-center gap-1 mt-2 text-emerald-500 font-bold text-xs uppercase tracking-tighter">
<TrendingUp size={14} />
<span>Sistem Aktif <span className="text-gray-400 font-medium lowercase">gerçek zamanlı veri</span></span>
</div>
</div>
</div>
{/* Total Customers */}
<div className="bg-white p-8 rounded-3xl border border-gray-100 shadow-sm space-y-4">
<div className="flex justify-between items-start">
<p className="text-sm font-bold text-gray-400 uppercase tracking-wider">Toplam Müşteri</p>
<div className="p-3 bg-indigo-50 rounded-xl text-indigo-600">
<Users size={20} />
</div>
</div>
<div>
<h3 className="text-3xl font-black text-gray-900">{stats.uniqueCustomers.toLocaleString('tr-TR')}</h3>
<div className="flex items-center gap-1 mt-2 text-emerald-500 font-bold text-xs uppercase tracking-tighter">
<TrendingUp size={14} />
<span>{stats.totalCount} <span className="text-gray-400 font-medium lowercase">toplam işlem kaydı</span></span>
</div>
</div>
</div>
{/* Pending Payments */}
<div className="bg-white p-8 rounded-3xl border border-gray-100 shadow-sm space-y-4">
<div className="flex justify-between items-start">
<p className="text-sm font-bold text-gray-400 uppercase tracking-wider">Bekleyen Ödemeler</p>
<div className="p-3 bg-orange-50 rounded-xl text-orange-600">
<ClipboardList size={20} />
</div>
</div>
<div>
<h3 className="text-3xl font-black text-gray-900">{stats.pendingCount}</h3>
<div className="flex items-center gap-1 mt-2 text-orange-500 font-bold text-xs uppercase tracking-tighter">
<ClipboardList size={14} />
<span>İşlem Bekliyor <span className="text-gray-400 font-medium lowercase">onay aşamasında</span></span>
</div>
</div>
</div>
{/* Success Rate */}
<div className="bg-white p-8 rounded-3xl border border-gray-100 shadow-sm space-y-4">
<div className="flex justify-between items-start">
<p className="text-sm font-bold text-gray-400 uppercase tracking-wider">Başarı Oranı</p>
<div className="p-3 bg-emerald-50 rounded-xl text-emerald-600">
<CheckCircle2 size={20} />
</div>
</div>
<div>
<h3 className="text-3xl font-black text-gray-900">{stats.successRate.toFixed(1)}%</h3>
<div className="flex items-center gap-1 mt-2 text-emerald-500 font-bold text-xs uppercase tracking-tighter">
<TrendingUp size={14} />
<span>Optimized <span className="text-gray-400 font-medium lowercase">ödeme dönüşüm oranı</span></span>
</div>
</div>
</div>
</div>
{/* Middle Section: Charts */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Transaction Volume Line Chart */}
<div className="lg:col-span-2 bg-white p-8 rounded-3xl border border-gray-100 shadow-sm">
<div className="flex justify-between items-center mb-10">
<div>
<h3 className="text-lg font-black text-gray-900 leading-none">İşlem Hacmi</h3>
<p className="text-xs text-gray-400 font-bold uppercase tracking-wider mt-2">Son 30 günlük toplam hacim</p>
</div>
<select className="bg-gray-50 border-none rounded-xl text-[10px] font-black uppercase tracking-widest px-4 py-2 outline-none">
<option>Son 30 Gün</option>
<option>Son 7 Gün</option>
</select>
</div>
<div className="h-64 relative flex items-end justify-between px-4 pb-12">
{/* Dynamic SVG Chart */}
{(() => {
const maxAmount = Math.max(...stats.chartData.map(d => d.amount), 100);
const points = stats.chartData.map((d, i) => ({
x: (i / 29) * 100, // 0 to 100%
y: 100 - (d.amount / maxAmount) * 80 - 10 // 10 to 90% (lower y is higher value)
}));
const dLine = points.reduce((acc, p, i) =>
i === 0 ? `M 0 ${p.y}` : `${acc} L ${p.x} ${p.y}`, ''
);
const dArea = `${dLine} L 100 100 L 0 100 Z`;
return (
<svg
viewBox="0 0 100 100"
className="absolute inset-0 w-full h-full text-blue-500 overflow-visible px-4 pt-10 pb-12"
preserveAspectRatio="none"
>
<path
d={dLine}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d={dArea}
fill="url(#chartGradient)"
stroke="none"
/>
<defs>
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.2" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
);
})()}
<div className="absolute inset-x-0 bottom-0 flex justify-between text-[10px] font-bold text-gray-300 uppercase px-8 pb-4 border-t border-gray-50 pt-4">
<span>{stats.chartData[0].displayDate}</span>
<span>{stats.chartData[10].displayDate}</span>
<span>{stats.chartData[20].displayDate}</span>
<span>Bugün</span>
</div>
</div>
</div>
{/* Revenue by Source Donut Chart */}
<div className="bg-white p-8 rounded-3xl border border-gray-100 shadow-sm flex flex-col">
<h3 className="text-lg font-black text-gray-900 leading-none mb-10 uppercase tracking-tight">Kaynak Bazlı Ciro</h3>
<div className="flex-1 flex flex-col items-center justify-center space-y-8">
<div className="relative w-48 h-48">
<svg className="w-full h-full -rotate-90">
<circle cx="96" cy="96" r="80" stroke="#F1F5F9" strokeWidth="24" fill="none" />
<circle cx="96" cy="96" r="80" stroke="#2563EB" strokeWidth="24" fill="none" strokeDasharray="502" strokeDashoffset="200" strokeLinecap="round" />
<circle cx="96" cy="96" r="80" stroke="#60A5FA" strokeWidth="24" fill="none" strokeDasharray="502" strokeDashoffset="400" strokeLinecap="round" />
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center text-sans">
<p className="text-2xl font-black text-gray-900 truncate max-w-[120px] text-center">{stats.totalRevenue.toLocaleString('tr-TR', { maximumFractionDigits: 0 })} </p>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Toplam Ciro</p>
</div>
</div>
<div className="grid grid-cols-2 gap-x-8 gap-y-4 w-full px-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-600"></div>
<span className="text-xs font-bold text-gray-900">Kart (60%)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-400"></div>
<span className="text-xs font-bold text-gray-900">Havale (20%)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-gray-200"></div>
<span className="text-xs font-bold text-gray-900">Cüzdan (15%)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-gray-100"></div>
<span className="text-xs font-bold text-gray-400 text-center uppercase tracking-tighter">Diğer (5%)</span>
</div>
</div>
</div>
</div>
</div>
{/* Bottom Section: Recent Transactions Table */}
<div className="bg-white rounded-[40px] border border-gray-100 shadow-sm overflow-hidden text-sans tracking-tight">
<div className="p-8 border-b border-gray-50 flex justify-between items-center text-sans tracking-tight">
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tight">Son İşlemler</h2>
<Link href="/admin/transactions" className="text-blue-600 text-xs font-black uppercase tracking-widest hover:underline decoration-2 underline-offset-4">
Tümünü Gör
</Link>
</div>
<div className="overflow-x-auto text-sans tracking-tight">
<table className="w-full text-left">
<thead>
<tr className="bg-gray-50/30 text-gray-400 text-[10px] font-black uppercase tracking-[0.2em] border-b border-gray-50">
<th className="px-10 py-6">İşlem ID</th>
<th className="px-10 py-6">Müşteri / Ref</th>
<th className="px-10 py-6">Tarih</th>
<th className="px-10 py-6 text-right pr-20">Tutar</th>
<th className="px-10 py-6 text-center">Durum</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{recentTransactions.map((t) => (
<tr key={t.id} className="group hover:bg-gray-50/50 transition-colors">
<td className="px-10 py-8">
<div className="flex flex-col">
<span className="text-sm font-black text-gray-900">#{t.stripe_pi_id.slice(-8).toUpperCase()}</span>
<span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider mt-1">{t.id.slice(0, 8)}</span>
</div>
</td>
<td className="px-10 py-8">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-blue-50 rounded-xl flex items-center justify-center text-blue-600 font-black text-xs uppercase tracking-tighter">
{t.customer_name ? t.customer_name.slice(0, 2).toUpperCase() : (t.source_ref_id ? t.source_ref_id.slice(0, 2).toUpperCase() : 'PI')}
</div>
<div className="flex flex-col">
<span className="text-sm font-black text-gray-900">{t.customer_name || t.source_ref_id || 'Sistem Ödemesi'}</span>
<span className="text-[10px] text-gray-400 font-bold truncate max-w-[150px] mt-1">{t.customer_phone || t.callback_url || 'doğrudan-ödeme'}</span>
</div>
</div>
</td>
<td className="px-10 py-8">
<span className="text-xs font-bold text-gray-500 uppercase">
{format(new Date(t.created_at), 'dd MMM yyyy', { locale: tr })}
</span>
</td>
<td className="px-10 py-8 text-right pr-20">
<span className="text-sm font-black text-gray-900">
{Number(t.amount).toLocaleString('tr-TR', { minimumFractionDigits: 2 })}
</span>
</td>
<td className="px-10 py-8">
<div className="flex justify-center">
<span className={`inline-flex items-center px-4 py-1 rounded-full text-[10px] font-black uppercase tracking-wider ${t.status === 'succeeded' ? 'bg-emerald-50 text-emerald-600' :
t.status === 'failed' ? 'bg-red-50 text-red-600' :
'bg-orange-50 text-orange-600'
}`}>
{t.status === 'succeeded' ? 'Başarılı' :
t.status === 'failed' ? 'Hatalı' : 'Bekliyor'}
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

138
app/admin/settings/page.tsx Normal file
View File

@@ -0,0 +1,138 @@
'use client';
import React from 'react';
import {
Globe,
ShieldCheck,
Bell,
Trash2,
Smartphone,
Monitor,
} from 'lucide-react';
export default function SettingsPage() {
return (
<div className="max-w-4xl space-y-10 animate-in fade-in slide-in-from-bottom-4 duration-700">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div>
<h1 className="text-3xl font-black text-gray-900 tracking-tight">Ayarlar</h1>
<p className="text-sm text-gray-400 font-bold uppercase tracking-widest mt-2">Platform tercihlerinizi yönetin</p>
</div>
<button className="px-8 py-4 bg-gray-900 text-white rounded-2xl font-black text-sm shadow-xl shadow-gray-200 hover:bg-gray-800 transition active:scale-95">
Değişiklikleri Kaydet
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-10">
{/* Left Column: Sections */}
<div className="lg:col-span-2 space-y-8">
{/* General Section */}
<section className="bg-white p-10 rounded-[40px] border border-gray-100 shadow-sm space-y-8">
<div className="flex items-center gap-4 border-b border-gray-50 pb-6">
<div className="w-12 h-12 bg-blue-50 rounded-2xl flex items-center justify-center text-blue-600">
<Globe size={24} />
</div>
<h2 className="text-xl font-black text-gray-900 uppercase tracking-tight">Genel Ayarlar</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-3">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest pl-2">Mağaza Adı</label>
<input type="text" defaultValue="froyd Store" className="w-full px-6 py-4 bg-gray-50 border-none rounded-2xl text-sm font-bold focus:ring-2 focus:ring-blue-500 outline-none" />
</div>
<div className="space-y-3">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest pl-2">Destek E-postası</label>
<input type="email" defaultValue="support@froyd.io" className="w-full px-6 py-4 bg-gray-50 border-none rounded-2xl text-sm font-bold focus:ring-2 focus:ring-blue-500 outline-none" />
</div>
<div className="space-y-3">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest pl-2">Para Birimi</label>
<select className="w-full px-6 py-4 bg-gray-50 border-none rounded-2xl text-sm font-bold focus:ring-2 focus:ring-blue-500 outline-none appearance-none">
<option>Türk Lirası ()</option>
<option>US Dollar ($)</option>
<option>Euro ()</option>
</select>
</div>
<div className="space-y-3">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest pl-2">Zaman Dilimi</label>
<select className="w-full px-6 py-4 bg-gray-50 border-none rounded-2xl text-sm font-bold focus:ring-2 focus:ring-blue-500 outline-none appearance-none">
<option>Istanbul (GMT+3)</option>
<option>London (GMT+0)</option>
<option>New York (EST)</option>
</select>
</div>
</div>
</section>
{/* Security Section */}
<section className="bg-white p-10 rounded-[40px] border border-gray-100 shadow-sm space-y-8">
<div className="flex items-center gap-4 border-b border-gray-50 pb-6">
<div className="w-12 h-12 bg-emerald-50 rounded-2xl flex items-center justify-center text-emerald-600">
<ShieldCheck size={24} />
</div>
<h2 className="text-xl font-black text-gray-900 uppercase tracking-tight">Güvenlik</h2>
</div>
<div className="space-y-6">
<div className="flex items-center justify-between p-6 bg-gray-50 rounded-[32px]">
<div className="space-y-1">
<p className="text-sm font-black text-gray-900">İki Faktörlü Doğrulama</p>
<p className="text-[10px] text-gray-400 font-bold uppercase">Hesabınıza ekstra bir güvenlik katmanı ekleyin</p>
</div>
<div className="w-12 h-6 bg-blue-500 rounded-full relative cursor-pointer">
<div className="absolute right-1 top-1 w-4 h-4 bg-white rounded-full"></div>
</div>
</div>
<div className="flex items-center justify-between p-6 bg-gray-50 rounded-[32px]">
<div className="space-y-1">
<p className="text-sm font-black text-gray-900">API Erişimi</p>
<p className="text-[10px] text-gray-400 font-bold uppercase">Harici uygulamalar için anahtar yönetimi</p>
</div>
<button className="text-blue-600 text-[10px] font-black uppercase tracking-widest hover:underline">Anahtarları Düzenle</button>
</div>
</div>
</section>
</div>
{/* Right Column: Notifications & Danger Zone */}
<div className="space-y-8">
<section className="bg-white p-8 rounded-[40px] border border-gray-100 shadow-sm space-y-6">
<div className="flex items-center gap-4 mb-2">
<div className="w-10 h-10 bg-orange-50 rounded-xl flex items-center justify-center text-orange-600">
<Bell size={20} />
</div>
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tight">Bildirimler</h2>
</div>
<div className="space-y-4">
{['Yeni Satışlar', 'Müşteri Mesajları', 'Sistem Güncellemeleri'].map((item) => (
<label key={item} className="flex items-center gap-4 cursor-pointer group">
<input type="checkbox" defaultChecked className="w-5 h-5 rounded-lg border-2 border-gray-100 text-blue-600 focus:ring-blue-500" />
<span className="text-sm font-bold text-gray-500 group-hover:text-gray-900 transition">{item}</span>
</label>
))}
</div>
</section>
<section className="bg-red-50 p-8 rounded-[40px] border border-red-100 space-y-6">
<div className="flex items-center gap-4 text-red-600">
<Trash2 size={20} />
<h2 className="text-lg font-black uppercase tracking-tight">Tehlikeli Bölge</h2>
</div>
<p className="text-xs font-bold text-red-400 leading-relaxed uppercase tracking-wider">
Mağaza verilerini kalıcı olarak silmek veya hesabı kapatmak için bu bölümü kullanın. Bu işlem geri alınamaz.
</p>
<button className="w-full py-4 bg-red-600 text-white rounded-2xl font-black text-sm hover:bg-red-700 transition shadow-lg shadow-red-100">
Mağazayı Devre Dışı Bırak
</button>
</section>
</div>
</div>
{/* Footer Meta */}
<div className="pt-12 text-center">
<p className="text-[10px] text-gray-300 font-black uppercase tracking-[0.4em]">v1.0.4 Platinum Enterprise Edition</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
import React from 'react';
import { supabaseAdmin } from '@/lib/supabase';
import {
Search,
Filter,
Download,
ExternalLink,
MoreVertical
} from 'lucide-react';
import { format } from 'date-fns';
import { tr } from 'date-fns/locale';
async function getTransactions() {
const { data, error } = await supabaseAdmin
.from('transactions')
.select('*')
.order('created_at', { ascending: false });
if (error) return [];
return data;
}
export default async function TransactionsPage() {
const transactions = await getTransactions();
return (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
{/* Search and Filters Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 bg-white p-8 rounded-[32px] border border-gray-100 shadow-sm">
<div className="flex items-center gap-4 flex-1">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" size={20} />
<input
type="text"
placeholder="İşlem ID veya referans ile ara..."
className="w-full pl-12 pr-6 py-3 bg-gray-50 border-none rounded-2xl text-sm font-medium focus:ring-2 focus:ring-blue-500 outline-none placeholder:text-gray-300"
/>
</div>
<button className="flex items-center gap-2 px-6 py-3 bg-white border border-gray-100 rounded-2xl text-sm font-bold text-gray-600 hover:bg-gray-50 transition">
<Filter size={18} />
Filtreler
</button>
</div>
<button className="flex items-center justify-center gap-2 px-6 py-3 bg-gray-900 text-white rounded-2xl text-sm font-bold hover:bg-gray-800 transition shadow-lg shadow-gray-200">
<Download size={18} />
CSV Olarak İndir
</button>
</div>
{/* Full Transactions Table */}
<div className="bg-white rounded-[40px] border border-gray-100 shadow-sm overflow-hidden text-sans tracking-tight">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="bg-gray-50/30 text-gray-400 text-[10px] font-black uppercase tracking-[0.2em] border-b border-gray-50">
<th className="px-10 py-6">İşlem ID</th>
<th className="px-10 py-6">Referans / Kaynak</th>
<th className="px-10 py-6">Tarih & Saat</th>
<th className="px-10 py-6">Tutar</th>
<th className="px-10 py-6 text-center">Durum</th>
<th className="px-10 py-6 text-right">İşlemler</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{transactions.map((t) => (
<tr key={t.id} className="group hover:bg-gray-50/50 transition-colors">
<td className="px-10 py-8">
<span className="text-sm font-black text-gray-900 uppercase">#{t.stripe_pi_id?.slice(-12).toUpperCase() || 'MOCK'}</span>
</td>
<td className="px-10 py-8">
<div className="flex flex-col">
<span className="text-sm font-bold text-gray-900">{t.customer_name || t.source_ref_id || 'Doğrudan Ödeme'}</span>
{t.customer_phone ? (
<span className="text-[10px] text-blue-600 font-black uppercase tracking-wider mt-1">{t.customer_phone}</span>
) : (
<span className="text-[10px] text-gray-400 font-bold truncate max-w-[200px] mt-1">{t.callback_url || 'Geri dönüş yok'}</span>
)}
</div>
</td>
<td className="px-10 py-8">
<span className="text-xs font-bold text-gray-500">
{format(new Date(t.created_at), 'dd MMM yyyy, HH:mm', { locale: tr })}
</span>
</td>
<td className="px-10 py-8 font-black text-gray-900">
{Number(t.amount).toLocaleString('tr-TR', { minimumFractionDigits: 2 })} {t.currency.toUpperCase() === 'TRY' ? '₺' : t.currency.toUpperCase()}
</td>
<td className="px-10 py-8">
<div className="flex justify-center">
<span className={`inline-flex items-center px-4 py-1 rounded-full text-[10px] font-black uppercase tracking-wider ${t.status === 'succeeded' ? 'bg-emerald-50 text-emerald-600' :
t.status === 'failed' ? 'bg-red-50 text-red-600' :
'bg-orange-50 text-orange-600'
}`}>
{t.status === 'succeeded' ? 'Başarılı' :
t.status === 'failed' ? 'Hatalı' : 'Bekliyor'}
</span>
</div>
</td>
<td className="px-10 py-8 text-right">
<div className="flex items-center justify-end gap-2">
{t.callback_url && (
<a href={t.callback_url} target="_blank" className="p-2 text-gray-300 hover:text-blue-600 transition">
<ExternalLink size={18} />
</a>
)}
<button className="p-2 text-gray-300 hover:text-gray-900 transition">
<MoreVertical size={18} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{transactions.length === 0 && (
<div className="p-20 text-center space-y-4">
<div className="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center mx-auto text-gray-300">
<Search size={32} />
</div>
<p className="text-gray-400 font-bold uppercase tracking-widest text-xs">İşlem bulunamadı</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { supabaseAdmin } from '@/lib/supabase';
export async function POST(req: NextRequest) {
try {
const { amount, currency, ref_id, callback_url, customer_name, customer_phone } = await req.json();
if (!amount || !currency) {
return NextResponse.json(
{ error: 'Tutar ve para birimi zorunludur.' },
{ status: 400 }
);
}
const useMock = process.env.NEXT_PUBLIC_USE_MOCK_PAYMENTS === 'true';
let clientSecret = 'mock_secret_' + Math.random().toString(36).substring(7);
let stripeId = 'mock_pi_' + Math.random().toString(36).substring(7);
if (!useMock) {
// 1. Create PaymentIntent in Stripe
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(amount * 100), // Stripe uses subunits (e.g., cents)
currency: currency.toLowerCase(),
metadata: {
ref_id,
callback_url,
customer_name,
customer_phone,
},
});
clientSecret = paymentIntent.client_secret!;
stripeId = paymentIntent.id;
}
// 2. Log transaction in Supabase with 'pending' status
const { error: dbError } = await supabaseAdmin
.from('transactions')
.insert({
amount,
currency,
status: 'pending',
stripe_pi_id: stripeId,
source_ref_id: ref_id,
customer_name,
customer_phone,
callback_url,
});
if (dbError) {
console.error('Database log error:', dbError);
}
return NextResponse.json({
clientSecret: clientSecret,
});
} catch (err: any) {
console.error('Internal Error:', err);
return NextResponse.json(
{ error: `Internal Server Error: ${err.message}` },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabaseAdmin } from '@/lib/supabase';
export async function POST(req: NextRequest) {
try {
const { clientSecret, status, customer_name, customer_phone } = await req.json();
if (process.env.NEXT_PUBLIC_USE_MOCK_PAYMENTS !== 'true') {
return NextResponse.json({ error: 'Mock payments are disabled' }, { status: 403 });
}
// Update transaction in Supabase
const { error } = await supabaseAdmin
.from('transactions')
.update({
status,
customer_name,
customer_phone
})
.eq('stripe_pi_id', clientSecret); // In mock mode, we use clientSecret as the ID
if (error) {
console.error('Mock update DB error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabaseAdmin } from '@/lib/supabase';
export async function POST(req: NextRequest) {
try {
const { stripe_id, customer_name, customer_phone } = await req.json();
if (!stripe_id) {
return NextResponse.json({ error: 'Missing stripe_id' }, { status: 400 });
}
const { error } = await supabaseAdmin
.from('transactions')
.update({
customer_name,
customer_phone
})
.eq('stripe_pi_id', stripe_id);
if (error) {
console.error('Update transaction info error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { supabaseAdmin } from '@/lib/supabase';
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event;
try {
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
} catch (err: any) {
console.error(`Webhook Error: ${err.message}`);
return NextResponse.json({ error: `Webhook Error: ${err.message}` }, { status: 400 });
}
const session = event.data.object as any;
// Handle the business logic based on event type
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSucceeded(session);
break;
case 'payment_intent.payment_failed':
await handlePaymentFailed(session);
break;
default:
console.log(`Unhandled event type ${event.type}`);
}
return NextResponse.json({ received: true });
}
async function handlePaymentSucceeded(paymentIntent: any) {
const { error } = await supabaseAdmin
.from('transactions')
.update({ status: 'succeeded' })
.eq('stripe_pi_id', paymentIntent.id);
if (error) {
console.error('Error updating transaction success:', error);
}
}
async function handlePaymentFailed(paymentIntent: any) {
const { error } = await supabaseAdmin
.from('transactions')
.update({ status: 'failed' })
.eq('stripe_pi_id', paymentIntent.id);
if (error) {
console.error('Error updating transaction failure:', error);
}
}

View File

@@ -0,0 +1,52 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { XCircle, ArrowLeft, RotateCcw } from 'lucide-react';
import Link from 'next/link';
import { Suspense } from 'react';
function ErrorContent() {
const searchParams = useSearchParams();
const callbackUrl = searchParams.get('callback_url') || '/';
return (
<div className="max-w-md mx-auto mt-20 p-8 text-center bg-white rounded-3xl shadow-xl border border-red-50">
<div className="flex justify-center mb-6">
<div className="bg-red-100 p-4 rounded-full">
<XCircle className="w-16 h-16 text-red-600" />
</div>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Ödeme Başarısız</h1>
<p className="text-gray-600 mb-8">
İşleminiz maalesef tamamlanamadı. Kart bilgilerinizi kontrol edip tekrar deneyebilirsiniz.
</p>
<div className="flex flex-col gap-3">
<Link
href="/checkout"
className="flex items-center justify-center gap-2 w-full py-4 bg-gray-900 text-white rounded-2xl font-bold hover:bg-gray-800 transition"
>
<RotateCcw size={18} />
Tekrar Dene
</Link>
<Link
href={callbackUrl}
className="flex items-center justify-center gap-2 w-full py-4 bg-white text-gray-600 border border-gray-100 rounded-2xl font-bold hover:bg-gray-50 transition"
>
<ArrowLeft size={18} />
Mağazaya Dön
</Link>
</div>
</div>
);
}
export default function CheckoutErrorPage() {
return (
<div className="min-h-screen bg-[#F8FAFC] flex items-center justify-center p-4">
<Suspense fallback={<div>Yükleniyor...</div>}>
<ErrorContent />
</Suspense>
</div>
);
}

163
app/checkout/page.tsx Normal file
View File

@@ -0,0 +1,163 @@
'use client';
import React, { useEffect, useState, Suspense } from 'react';
import { Elements } from '@stripe/react-stripe-js';
import { getStripe } from '@/lib/stripe-client';
import { useSearchParams } from 'next/navigation';
import CheckoutForm from '@/components/checkout/CheckoutForm';
import MockCheckoutForm from '@/components/checkout/MockCheckoutForm';
import { Loader2, AlertCircle, ArrowLeft, UserCircle } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link'; // Added Link import
function CheckoutContent() {
const searchParams = useSearchParams();
const amount = parseFloat(searchParams.get('amount') || '100');
const currency = searchParams.get('currency') || 'TL';
const refId = searchParams.get('ref_id') || 'SEC-99231-TX';
const callbackUrl = searchParams.get('callback_url') || '/';
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const isMock = process.env.NEXT_PUBLIC_USE_MOCK_PAYMENTS === 'true';
useEffect(() => {
if (amount <= 0) {
setError('Geçersiz işlem tutarı.');
return;
}
fetch('/api/create-payment-intent', {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount, currency, ref_id: refId, callback_url: callbackUrl }),
})
.then((res) => res.json())
.then((data) => {
if (data.error) {
setError(data.error);
} else {
setClientSecret(data.clientSecret);
}
})
.catch(() => setError('Ödeme başlatılamadı. Lütfen tekrar deneyin.'));
}, [amount, currency, refId, callbackUrl]);
if (error) {
return (
<div className="flex flex-col items-center justify-center p-8 bg-red-50 rounded-2xl border border-red-100 max-w-md mx-auto mt-20">
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
<h2 className="text-xl font-bold text-red-700 mb-2">Hata Oluştu</h2>
<p className="text-red-600 text-center">{error}</p>
<button
onClick={() => window.location.reload()}
className="mt-6 px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
>
Tekrar Dene
</button>
</div>
);
}
return (
<div className="min-h-screen bg-[#F8FAFC] flex flex-col">
{/* Header */}
<nav className="bg-white border-b border-gray-100 px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
<div className="w-4 h-4 bg-white rotate-45 transform"></div>
</div>
<span className="font-bold text-gray-900 text-lg tracking-tight">froydPay</span>
</div>
<div className="w-8 h-8 bg-orange-100 rounded-full flex items-center justify-center text-orange-500">
<UserCircle size={24} />
</div>
</nav>
{/* Main Content */}
<div className="flex-1 flex flex-col lg:flex-row items-stretch max-w-7xl mx-auto w-full px-6 py-12 gap-12">
{/* Left Column: Product Info */}
<div className="flex-1 flex flex-col justify-center items-center lg:items-end space-y-8 order-2 lg:order-1">
<div className="relative group perspective-1000">
<div className="w-full max-w-[400px] aspect-square relative rounded-[40px] overflow-hidden shadow-2xl shadow-blue-200/50 transform group-hover:rotate-y-6 transition-transform duration-500 border-8 border-white">
<Image
src="/digital_nft_asset.png"
alt="Digital NFT Product"
fill
className="object-cover"
priority
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-8 pt-20">
<span className="text-blue-400 text-[10px] font-bold uppercase tracking-widest mb-2 block">Premium Dijital Varlık</span>
<h3 className="text-white text-2xl font-black tracking-tight uppercase">CyberCube #082</h3>
<p className="text-gray-300 text-sm mt-2 line-clamp-2">froyd ına ömür boyu erişim sağlayan özel, yüksek sadakatli 3D üretken dijital koleksiyon parçası.</p>
</div>
</div>
{/* Gloss Effect */}
<div className="absolute inset-0 rounded-[40px] bg-gradient-to-tr from-white/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div>
</div>
<div className="text-center lg:text-right space-y-2 hidden lg:block">
<p className="text-gray-400 text-sm font-medium">Satıcı: <span className="text-gray-900 uppercase">Froyd Digital Media INC.</span></p>
<p className="text-gray-400 text-sm">Müşteri Desteği: <span className="text-blue-600 hover:underline cursor-pointer">help@froyd.io</span></p>
</div>
</div>
{/* Right Column: Payment Form */}
<div className="flex-1 flex flex-col justify-center items-center lg:items-start order-1 lg:order-2">
{!clientSecret ? (
<div className="flex flex-col items-center justify-center p-20 bg-white rounded-3xl border border-gray-100 shadow-sm w-full max-w-md">
<Loader2 className="w-10 h-10 text-blue-600 animate-spin mb-4" />
<p className="text-gray-500 font-medium">Ödeme ekranı hazırlanıyor...</p>
</div>
) : (
<div className="w-full">
{isMock ? (
<MockCheckoutForm amount={amount} currency={currency} callbackUrl={callbackUrl} clientSecret={clientSecret} refId={refId} />
) : (
<Elements stripe={getStripe()} options={{ clientSecret, appearance: { theme: 'stripe' } }}>
<CheckoutForm
amount={amount}
currency={currency}
callbackUrl={callbackUrl}
piId={clientSecret.split('_secret')[0]}
/>
</Elements>
)}
<div className="mt-8 flex justify-center lg:justify-start">
<Link href={callbackUrl} className="flex items-center gap-2 text-sm font-semibold text-gray-500 hover:text-gray-900 transition translate-x-0 hover:-translate-x-1 duration-200">
<ArrowLeft size={16} />
Mağazaya Dön
</Link>
</div>
</div>
)}
</div>
</div>
{/* Footer */}
<footer className="py-12 border-t border-gray-100 text-center">
<p className="text-[#94A3B8] text-[10px] font-medium tracking-tight uppercase">
© 2026 froydPay Inc. Tüm hakları saklıdır.
</p>
</footer>
</div>
);
}
export default function CheckoutPage() {
return (
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<Suspense fallback={
<div className="flex justify-center items-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
</div>
}>
<CheckoutContent />
</Suspense>
</div>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import React, { useEffect, Suspense } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { CheckCircle2, Loader2 } from 'lucide-react';
function SuccessContent() {
const searchParams = useSearchParams();
const router = useRouter();
const callbackUrl = searchParams.get('callback_url');
const paymentIntent = searchParams.get('payment_intent');
useEffect(() => {
if (callbackUrl) {
// Redirect after a short delay
const timer = setTimeout(() => {
try {
// Handle potential relative URLs by providing a base
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
const url = new URL(callbackUrl, baseUrl);
url.searchParams.append('status', 'success');
if (paymentIntent) url.searchParams.append('payment_intent', paymentIntent);
window.location.href = url.toString();
} catch (e) {
console.error('URL parse error:', e);
// Fallback to direct navigation if URL parsing fails
window.location.href = callbackUrl;
}
}, 3000);
return () => clearTimeout(timer);
}
}, [callbackUrl, paymentIntent]);
return (
<div className="max-w-md mx-auto mt-20 p-8 text-center bg-white rounded-3xl shadow-xl border border-green-50">
<div className="flex justify-center mb-6">
<div className="bg-green-100 p-4 rounded-full">
<CheckCircle2 className="w-16 h-16 text-green-600" />
</div>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Ödeme Başarılı!</h1>
<p className="text-gray-600 mb-8">
İşleminiz başarıyla tamamlandı. Birazdan geldiğiniz sayfaya yönlendirileceksiniz.
</p>
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-6 h-6 text-green-600 animate-spin" />
<p className="text-sm text-gray-400">Yönlendiriliyor...</p>
{callbackUrl && (
<a
href={callbackUrl}
className="text-blue-600 hover:underline text-sm font-medium mt-4"
>
Yönlendirme olmazsa buraya tıklayın
</a>
)}
</div>
</div>
);
}
export default function SuccessPage() {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<Suspense fallback={<Loader2 className="w-8 h-8 animate-spin text-blue-600" />}>
<SuccessContent />
</Suspense>
</div>
);
}

99
app/login/page.tsx Normal file
View File

@@ -0,0 +1,99 @@
'use client';
import { useState } from 'react';
import { createClient } from '@/utils/supabase/client';
import { useRouter } from 'next/navigation';
import { Lock, Mail, Loader2, Wallet } from 'lucide-react';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const supabase = createClient();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError(null);
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
setError(error.message);
setIsLoading(false);
} else {
router.push('/admin');
router.refresh();
}
};
return (
<div className="min-h-screen bg-[#F8FAFC] flex items-center justify-center p-4">
<div className="w-full max-w-md space-y-8 bg-white p-10 rounded-[40px] border border-gray-100 shadow-xl shadow-blue-50">
<div className="text-center space-y-4">
<div className="mx-auto w-16 h-16 bg-[#2563EB] rounded-2xl flex items-center justify-center text-white shadow-lg shadow-blue-100">
<Wallet size={32} />
</div>
<div>
<h1 className="text-2xl font-black text-gray-900 tracking-tight">Admin Login</h1>
<p className="text-sm text-gray-400 font-bold uppercase tracking-widest mt-1">Management Portal</p>
</div>
</div>
<form onSubmit={handleLogin} className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest pl-2">Email Address</label>
<div className="relative">
<Mail className="absolute left-5 top-1/2 -translate-y-1/2 text-gray-300" size={20} />
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@froyd.io"
className="w-full pl-14 pr-6 py-4 bg-gray-50 border-none rounded-2xl text-sm font-medium focus:ring-2 focus:ring-blue-500 outline-none"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest pl-2">Password</label>
<div className="relative">
<Lock className="absolute left-5 top-1/2 -translate-y-1/2 text-gray-300" size={20} />
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full pl-14 pr-6 py-4 bg-gray-50 border-none rounded-2xl text-sm font-medium focus:ring-2 focus:ring-blue-500 outline-none"
/>
</div>
</div>
{error && (
<div className="p-4 bg-red-50 text-red-600 text-xs font-bold rounded-2xl border border-red-100 animate-shake">
{error}
</div>
)}
<button
disabled={isLoading}
className="w-full bg-gray-900 text-white font-black py-5 rounded-2xl transition duration-300 shadow-lg hover:bg-gray-800 disabled:opacity-70 flex items-center justify-center"
>
{isLoading ? <Loader2 className="animate-spin" /> : 'Log In to Dashboard'}
</button>
</form>
<p className="text-center text-[10px] text-gray-300 font-bold uppercase tracking-widest">
Secure encrypted session
</p>
</div>
</div>
);
}

View File

@@ -1,65 +1,93 @@
import Image from "next/image";
'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import { ShieldCheck, CreditCard, LayoutDashboard, Zap } from 'lucide-react';
export default function Home() {
const [randomAmount, setRandomAmount] = useState(150);
const [refId, setRefId] = useState('DEMO-123');
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
// Random amount between 50 and 5000
setRandomAmount(Math.floor(Math.random() * 4950) + 50);
// Random ref id
setRefId(`DEMO-${Math.floor(Math.random() * 900) + 100}`);
}, []);
// Return a static version or null during SSR to avoid mismatch
// Or just use the state which will be '150' and 'DEMO-123' on server
// and then update on client. The mismatch happens because of Math.random() in JSX.
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<div className="min-h-screen bg-white">
{/* Hero Section */}
<header className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-20 pb-16 text-center">
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full bg-blue-50 text-blue-700 text-sm font-medium mb-6">
<Zap size={16} />
<span>v1.0.0 Yayında</span>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
<h1 className="text-5xl md:text-7xl font-black text-gray-900 tracking-tight mb-8">
Ödemelerinizi <span className="text-blue-600 underline decoration-blue-200 underline-offset-8">froyd</span> ile Güvenceye Alın
</h1>
<p className="text-xl text-gray-600 max-w-2xl mx-auto mb-10">
Stripe altyapısı ile projelerinize kolayca ödeme geçidi ekleyin. Merkezi yönetim paneli ile tüm işlemlerinizi tek bir yerden takip edin.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link
href={`/checkout?amount=${randomAmount}&currency=TRY&ref_id=${refId}&callback_url=http://localhost:3000`}
className="px-8 py-4 bg-gray-900 text-white rounded-2xl font-bold text-lg hover:bg-gray-800 transition shadow-xl transition-all hover:scale-105 active:scale-95"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
{mounted ? `Test Ödemesi Başlat (${randomAmount.toLocaleString('tr-TR')} ₺)` : 'Ödeme Sayfasını Test Et'}
</Link>
<Link
href="/admin"
className="px-8 py-4 bg-white text-gray-900 border-2 border-gray-100 rounded-2xl font-bold text-lg hover:border-gray-200 transition"
>
Documentation
</a>
Admin Panelini Gör
</Link>
</div>
</main>
</header>
{/* Features */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 border-t border-gray-50">
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
<div className="space-y-4">
<div className="bg-blue-600 w-12 h-12 rounded-2xl flex items-center justify-center text-white shadow-lg shadow-blue-200">
<ShieldCheck size={24} />
</div>
<h3 className="text-xl font-bold text-gray-900">Güvenli Altyapı</h3>
<p className="text-gray-600 leading-relaxed">
Stripe Elements kullanarak kart bilgilerini asla sunucularınızda saklamazsınız. Tam güvenlik garantisi.
</p>
</div>
<div className="space-y-4">
<div className="bg-purple-600 w-12 h-12 rounded-2xl flex items-center justify-center text-white shadow-lg shadow-purple-200">
<CreditCard size={24} />
</div>
<h3 className="text-xl font-bold text-gray-900">Dinamik Ödeme</h3>
<p className="text-gray-600 leading-relaxed">
Herhangi bir URL parametresi ile ödeme başlatın. Projelerinize entegre etmek sadece bir dakika sürer.
</p>
</div>
<div className="space-y-4">
<div className="bg-orange-600 w-12 h-12 rounded-2xl flex items-center justify-center text-white shadow-lg shadow-orange-200">
<LayoutDashboard size={24} />
</div>
<h3 className="text-xl font-bold text-gray-900">Merkezi Takip</h3>
<p className="text-gray-600 leading-relaxed">
Tüm projelerinizden gelen ödemeleri tek bir admin panelinden, anlık grafikler ve raporlarla izleyin.
</p>
</div>
</div>
</section>
{/* Footer */}
<footer className="py-12 border-t border-gray-100 text-center">
<p className="text-gray-400 text-sm">© 2026 froyd Payment Platforms. Tüm hakları saklıdır.</p>
</footer>
</div>
);
}

View File

@@ -0,0 +1,159 @@
'use client';
import React, { useState } from 'react';
import {
PaymentElement,
useStripe,
useElements,
} from '@stripe/react-stripe-js';
import { Loader2, Lock, ShieldCheck, HelpCircle } from 'lucide-react';
interface CheckoutFormProps {
amount: number;
currency: string;
callbackUrl: string;
piId: string;
}
export default function CheckoutForm({ amount, currency, callbackUrl, piId }: CheckoutFormProps) {
const stripe = useStripe();
const elements = useElements();
const [message, setMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!stripe || !elements) {
return;
}
if (!name || !phone) {
setMessage("Lütfen ad soyad ve telefon numaranızı giriniz.");
return;
}
setIsLoading(true);
// 1. Update customer info in our database
try {
await fetch('/api/update-transaction-info', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
stripe_id: piId,
customer_name: name,
customer_phone: phone,
}),
});
} catch (err) {
console.error('Info update error:', err);
}
// 2. Confirm payment in Stripe
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/checkout/success?callback_url=${encodeURIComponent(callbackUrl)}`,
payment_method_data: {
billing_details: {
name: name,
phone: phone,
}
}
},
});
if (error.type === "card_error" || error.type === "validation_error") {
setMessage(error.message ?? "Bir hata oluştu.");
} else {
setMessage("Beklenmedik bir hata oluştu.");
}
setIsLoading(false);
};
return (
<form id="payment-form" onSubmit={handleSubmit} className="w-full max-w-md mx-auto bg-white rounded-3xl shadow-sm border border-gray-100 p-8 space-y-8">
<div className="text-center space-y-1">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">TOPLAM TUTAR</p>
<h2 className="text-4xl font-extrabold text-gray-900 leading-none">
{amount.toLocaleString('tr-TR', { minimumFractionDigits: 2 })} {currency.toUpperCase() === 'TRY' || currency.toUpperCase() === 'TL' ? '₺' : currency.toUpperCase()}
</h2>
<p className="text-xs text-blue-500 font-medium tracking-tight">
Güvenli ve Şifreli İşlem
</p>
</div>
<div className="space-y-6">
{/* Customer Details */}
<div className="grid grid-cols-1 gap-4 pb-4 border-b border-gray-50">
<div className="space-y-2">
<label className="text-xs font-bold text-gray-700 uppercase">Ad Soyad</label>
<input
type="text"
placeholder="John Doe"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full p-4 bg-gray-50 border border-gray-200 rounded-2xl text-gray-900 focus:ring-2 focus:ring-blue-500 outline-none text-sm font-bold"
required
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-gray-700 uppercase">Telefon Numarası</label>
<input
type="tel"
placeholder="05XX XXX XX XX"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="w-full p-4 bg-gray-50 border border-gray-200 rounded-2xl text-gray-900 focus:ring-2 focus:ring-blue-500 outline-none text-sm font-bold"
required
/>
</div>
</div>
<PaymentElement id="payment-element" options={{ layout: 'tabs' }} />
{message && (
<div id="payment-message" className="p-4 bg-red-50 text-red-700 text-xs font-bold rounded-2xl border border-red-100">
{message}
</div>
)}
<button
disabled={isLoading || !stripe || !elements}
id="submit"
className="w-full bg-[#2563EB] hover:bg-blue-700 text-white font-bold py-5 rounded-2xl transition duration-300 flex items-center justify-center gap-3 shadow-lg shadow-blue-100 disabled:opacity-70 group"
>
{isLoading ? (
<Loader2 className="animate-spin w-5 h-5 text-white" />
) : (
<>
<Lock size={18} className="text-blue-200 group-hover:scale-110 transition-transform" />
<span>Güvenle Öde: {amount.toLocaleString('tr-TR', { minimumFractionDigits: 2 })} {currency.toUpperCase() === 'TRY' || currency.toUpperCase() === 'TL' ? '₺' : currency.toUpperCase()}</span>
</>
)}
</button>
</div>
<div className="space-y-4 text-center border-t border-gray-50 pt-8">
<div className="flex items-center justify-center gap-2">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-wide">256-BIT SSL ŞİFRELİ İŞLEM</p>
</div>
<div className="flex items-center justify-center gap-6 opacity-40">
<div className="flex items-center gap-1 text-[8px] font-black gray-800">
<span className="bg-gray-800 text-white px-1 rounded">Stripe</span> GÜVENCESİYLE
</div>
<div className="flex items-center gap-1 text-[8px] font-black text-gray-800 uppercase tracking-tighter">
<ShieldCheck size={10} /> PCI DSS UYUMLU
</div>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,184 @@
'use client';
import React, { useState } from 'react';
import { Loader2, CreditCard, Lock, ShieldCheck, HelpCircle } from 'lucide-react';
import { useRouter } from 'next/navigation';
interface MockCheckoutFormProps {
amount: number;
currency: string;
callbackUrl: string;
clientSecret: string;
refId?: string;
}
export default function MockCheckoutForm({ amount, currency, callbackUrl, clientSecret, refId }: MockCheckoutFormProps) {
const router = useRouter();
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [status, setStatus] = useState<'idle' | 'processing'>('idle');
const handleMockPayment = async (mode: 'success' | 'failed') => {
if (mode === 'success' && (!name || !phone)) {
alert('Lütfen ad soyad ve telefon numaranızı giriniz.');
return;
}
setIsLoading(true);
setStatus('processing');
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 2000));
if (mode === 'success') {
try {
await fetch('/api/mock-complete-payment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientSecret,
status: 'succeeded',
customer_name: name,
customer_phone: phone
}),
});
} catch (e) {
console.error('Mock update fail', e);
}
router.push(`/checkout/success?callback_url=${encodeURIComponent(callbackUrl)}&payment_intent=${clientSecret}`);
} else {
alert('Ödeme başarısız (Test Modu)');
setIsLoading(false);
setStatus('idle');
}
};
return (
<div className="w-full max-w-md mx-auto bg-white rounded-3xl shadow-sm border border-gray-100 p-8 space-y-8">
<div className="text-center space-y-1">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">TOPLAM TUTAR</p>
<h2 className="text-4xl font-extrabold text-gray-900 leading-none">
{amount.toLocaleString('tr-TR', { minimumFractionDigits: 2 })} {currency.toUpperCase() === 'TRY' || currency.toUpperCase() === 'TL' ? '₺' : currency.toUpperCase()}
</h2>
{refId && (
<p className="text-xs text-blue-500 font-medium tracking-tight">
Referans: #{refId}
</p>
)}
</div>
<div className="space-y-5">
{/* Customer Details */}
<div className="grid grid-cols-1 gap-4 pb-4 border-b border-gray-50">
<div className="space-y-2">
<label className="text-xs font-bold text-gray-700 uppercase">Ad Soyad</label>
<input
type="text"
placeholder="John Doe"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full p-4 bg-gray-50 border border-gray-200 rounded-2xl text-gray-900 focus:ring-2 focus:ring-blue-500 outline-none text-sm font-bold"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-gray-700 uppercase">Telefon Numarası</label>
<input
type="tel"
placeholder="05XX XXX XX XX"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="w-full p-4 bg-gray-50 border border-gray-200 rounded-2xl text-gray-900 focus:ring-2 focus:ring-blue-500 outline-none text-sm font-bold"
/>
</div>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 text-xs font-bold text-gray-700 uppercase">
<CreditCard size={14} className="text-gray-400" />
Kart Numarası
</label>
<div className="relative">
<input
type="text"
readOnly
value="4242 4242 4242 4242"
className="w-full p-4 bg-gray-50 border border-gray-200 rounded-2xl text-gray-900 focus:outline-none text-lg font-mono tracking-wider"
/>
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex gap-1">
<span className="w-8 h-5 bg-gray-200 rounded text-[8px] flex items-center justify-center text-gray-400 font-bold uppercase italic border border-gray-300">VISA</span>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs font-bold text-gray-700 uppercase">Son Kullanma</label>
<input
type="text"
readOnly
value="12/26"
className="w-full p-4 bg-gray-50 border border-gray-200 rounded-2xl text-gray-900 focus:outline-none text-lg font-mono tracking-wider"
/>
</div>
<div className="space-y-2">
<label className="flex items-center justify-between text-xs font-bold text-gray-700 uppercase">
CVC
<HelpCircle size={14} className="text-gray-300" />
</label>
<input
type="password"
readOnly
value="***"
className="w-full p-4 bg-gray-50 border border-gray-200 rounded-2xl text-gray-900 focus:outline-none text-lg font-mono tracking-widest"
/>
</div>
</div>
<div className="pt-2">
<button
onClick={() => handleMockPayment('success')}
disabled={isLoading}
className="w-full bg-[#2563EB] hover:bg-blue-700 text-white font-bold py-5 rounded-2xl transition duration-300 flex items-center justify-center gap-3 shadow-lg shadow-blue-100 disabled:opacity-70 group"
>
{isLoading ? (
<Loader2 className="animate-spin w-5 h-5 text-white" />
) : (
<>
<Lock size={18} className="text-blue-200 group-hover:scale-110 transition-transform" />
<span>Güvenle Öde: {amount.toLocaleString('tr-TR', { minimumFractionDigits: 2 })} {currency.toUpperCase() === 'TRY' || currency.toUpperCase() === 'TL' ? '₺' : currency.toUpperCase()}</span>
</>
)}
</button>
</div>
{/* Failed scenario trigger for testing */}
{process.env.NEXT_PUBLIC_USE_MOCK_PAYMENTS === 'true' && !isLoading && (
<button
onClick={() => handleMockPayment('failed')}
className="w-full text-[10px] text-gray-300 hover:text-red-400 transition uppercase tracking-widest font-bold"
>
Hata Testi Yap (Sadece Test Modu)
</button>
)}
</div>
<div className="space-y-4 text-center border-t border-gray-50 pt-8">
<div className="flex items-center justify-center gap-2">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-wide">256-BIT SSL ŞİFRELİ İŞLEM</p>
</div>
<div className="flex items-center justify-center gap-6 opacity-40">
<div className="flex items-center gap-1 text-[8px] font-black gray-800">
<span className="bg-gray-800 text-white px-1 rounded">Stripe</span> GÜVENCESİYLE
</div>
<div className="flex items-center gap-1 text-[8px] font-black text-gray-800 uppercase tracking-tighter">
<ShieldCheck size={10} /> PCI DSS UYUMLU
</div>
</div>
</div>
</div>
);
}

136
docs/.prd Normal file
View File

@@ -0,0 +1,136 @@
Ürün Gereksinim Belgesi (PRD)
Proje Adı: Stripe Ödeme Geçidi Ara Katmanı (Payment Gateway Middleware)
Versiyon: 1.0
Tarih: 15 Ocak 2026
Durum: Taslak
1. Proje Özeti
Bu proje, harici web sitelerinden (istemci) gelen ödeme taleplerini karşılayan, Stripe altyapısını kullanarak tahsilatı gerçekleştiren ve kullanıcıyı işlem sonucuna göre ilgili web sitesine geri yönlendiren merkezi bir ödeme sayfası ve yönetim panelidir.
Temel Amaç: Farklı projeler veya web siteleri için tek bir noktadan güvenli ödeme almak ve tüm işlemleri tek bir admin panelinden takip etmek.
2. Teknik Yığın (Tech Stack)
Frontend & Backend: Next.js 15 (App Router, Server Actions)
Veritabanı & Kimlik Doğrulama: Supabase (PostgreSQL, Auth)
Ödeme Altyapısı: Stripe (Stripe Elements & Webhooks)
UI Framework: Tailwind CSS + Shadcn/UI
Deployment: Vercel (Önerilen)
3. Kullanıcı Rolleri
Son Kullanıcı (Payer): Ödemeyi yapacak olan kişi.
Sistem Admini: Ödemeleri izleyen, iade (refund) işlemlerini yöneten ve raporları gören yetkili.
Entegre Sistem (Client): Kullanıcıyı ödeme sayfasına yönlendiren harici web sitesi.
4. Kullanıcı Akışları (User Flows)
4.1. Ödeme Akışı
Başlatma: Kullanıcı, harici siteden (örn: alisveris.com) "Öde" butonuna basar.
Yönlendirme: Kullanıcı şu parametrelerle sisteme gelir:
amount: Tutar (örn: 100)
currency: Para birimi (örn: TRY)
ref_id: Harici sitedeki sipariş no (örn: SIP-999)
callback_url: İşlem bitince dönülecek URL
client_secret (Opsiyonel/Güvenlik): Tutarlılığı doğrulamak için imza.
Ödeme Sayfası: Sistem parametreleri doğrular, Stripe üzerinde bir PaymentIntent oluşturur ve kullanıcıya kredi kartı formunu gösterir.
İşlem: Kullanıcı kart bilgilerini girer ve onaylar.
Sonuç:
Başarılı: Supabase güncellenir -> Kullanıcı callback_url?status=success&ref_id=... adresine yönlendirilir.
Başarısız: Hata mesajı gösterilir -> Kullanıcı tekrar denemeye veya callback_url?status=failed adresine yönlendirilir.
4.2. Admin Paneli Akışı
Admin, /admin rotasından Supabase Auth ile giriş yapar.
Dashboard'da günlük toplam ciro ve son işlemleri görür.
İşlem listesinde tarih, tutar, durum ve kaynak siteye göre filtreleme yapar.
5. Fonksiyonel Gereksinimler
5.1. Ödeme Arayüzü (Checkout Page)
Dinamik Tutar: URL parametresinden gelen tutarı ekranda göstermelidir.
Stripe Elements: Kart numarası, SKT ve CVC için Stripe'ın güvenli iframe (Elements) yapısı kullanılmalıdır.
Validasyon: Eksik parametre ile gelindiyse (örn: tutar yoksa) kullanıcıya "Geçersiz İşlem" hata sayfası gösterilmelidir.
Loading State: Ödeme işlenirken buton "İşleniyor..." durumuna geçmeli ve tekrar tıklama engellenmelidir.
5.2. Backend & API (Next.js Server Actions)
Create Payment Intent: Sayfa yüklendiğinde Stripe API ile iletişim kurup bir ödeme oturumu başlatmalıdır.
Webhook Listener: Stripe'tan gelen asenkron payment_intent.succeeded ve payment_intent.payment_failed olaylarını dinleyen bir API route (/api/webhooks/stripe) olmalıdır.
Kritik: Veritabanındaki ödeme durumu sadece Webhook'tan gelen bilgiye göre "Paid" olarak işaretlenmelidir (Kullanıcı tarayıcıyı kapatsa bile işlem kaydedilmelidir).
5.3. Admin Paneli
Oturum Yönetimi: Sadece belirli e-posta adreslerine sahip adminler giriş yapabilmelidir.
İşlem Listesi Tablosu:
Sütunlar: ID, Tutar, Para Birimi, Durum (Badge), Kaynak URL, Referans ID, Tarih.
Filtreler: Başarılı/Başarısız, Tarih Aralığı.
Dashboard Widgetları:
Toplam Ciro (Total Revenue)
Başarılı İşlem Sayısı
Başarısız İşlem Oranı
6. Veritabanı Şeması (Supabase)
Tablo Adı: transactions
Kolon Adı Veri Tipi Açıklama
id UUID (PK) Benzersiz işlem ID'si
created_at Timestamptz İşlem oluşturulma tarihi
amount Numeric Tutar (Örn: 150.50)
currency Text Para birimi (TRY, USD)
status Text pending, succeeded, failed
stripe_pi_id Text Stripe Payment Intent ID
source_ref_id Text Harici sitenin sipariş numarası
callback_url Text Geri dönüş URL'i
metadata JSONB Ekstra bilgiler (Müşteri IP, email vb.)
7. Güvenlik Gereksinimleri
SSL: Tüm sistem HTTPS üzerinden çalışmalıdır.
Environment Variables: Stripe Secret Key ve Supabase Key'leri asla client-side kodunda ifşa edilmemelidir.
CSRF Koruması: Next.js yerleşik korumaları aktif olmalıdır.
Basit Güvenlik (V1 için): Harici site ile backend arasında bir "Secret Key" belirlenip, URL manipülasyonunu önlemek için basit bir hash kontrolü eklenebilir (İleri fazda önerilir).
8. Geliştirme Yol Haritası (Fazlar)
Faz 1: Next.js kurulumu, Supabase bağlantısı ve Veritabanı tablosunun oluşturulması.
Faz 2: Stripe entegrasyonu ve Ödeme Sayfası (Checkout) tasarımı.
Faz 3: Webhook kurulumu (Ödemenin veritabanına işlenmesi).
Faz 4: Admin Paneli geliştirilmesi.
Faz 5: Test ve Canlıya Alım (Deployment).

37
docs/db_schema.sql Normal file
View File

@@ -0,0 +1,37 @@
-- Admin users table
CREATE TABLE admin_users (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Register initial admin (User should replace this or add via dashboard)
-- INSERT INTO admin_users (email) VALUES ('your-email@example.com');
-- Transactions table
CREATE TABLE transactions (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
amount NUMERIC NOT NULL,
currency TEXT NOT NULL DEFAULT 'TRY',
status TEXT NOT NULL DEFAULT 'succeeded' CHECK (status IN ('pending', 'succeeded', 'failed')),
stripe_pi_id TEXT UNIQUE,
source_ref_id TEXT,
customer_name TEXT,
customer_phone TEXT,
callback_url TEXT,
metadata JSONB DEFAULT '{}'::jsonb
);
-- Enable RLS
ALTER TABLE transactions ENABLE ROW LEVEL SECURITY;
-- Create policy for admins to read all
CREATE POLICY "Admins can read all transactions" ON transactions
FOR SELECT
USING (auth.jwt() ->> 'email' IN (SELECT email FROM admin_users));
-- Create policy for service role to manage all
CREATE POLICY "Service role can manage all" ON transactions
USING (true)
WITH CHECK (true);

10
lib/stripe-client.ts Normal file
View File

@@ -0,0 +1,10 @@
import { loadStripe, Stripe } from '@stripe/stripe-js';
let stripePromise: Promise<Stripe | null>;
export const getStripe = () => {
if (!stripePromise) {
stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
}
return stripePromise;
};

6
lib/stripe.ts Normal file
View File

@@ -0,0 +1,6 @@
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-01-27.acacia' as any, // Use the latest stable version
typescript: true,
});

11
lib/supabase.ts Normal file
View File

@@ -0,0 +1,11 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
export const supabaseAdmin = createClient(
supabaseUrl,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);

20
middleware.ts Normal file
View File

@@ -0,0 +1,20 @@
import { type NextRequest } from 'next/server'
import { updateSession } from '@/utils/supabase/middleware'
export async function middleware(request: NextRequest) {
return await updateSession(request)
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public files (images, etc)
* Feel free to modify this pattern to include more paths.
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}

286
package-lock.json generated
View File

@@ -8,9 +8,18 @@
"name": "froyd",
"version": "0.1.0",
"dependencies": {
"@stripe/react-stripe-js": "^5.4.1",
"@stripe/stripe-js": "^8.6.1",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.90.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.562.0",
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"stripe": "^20.1.2",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -1234,6 +1243,123 @@
"dev": true,
"license": "MIT"
},
"node_modules/@stripe/react-stripe-js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.4.1.tgz",
"integrity": "sha512-ipeYcAHa4EPmjwfv0lFE+YDVkOQ0TMKkFWamW+BqmnSkEln/hO8rmxGPPWcd9WjqABx6Ro8Xg4pAS7evCcR9cw==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.7.2"
},
"peerDependencies": {
"@stripe/stripe-js": ">=8.0.0 <9.0.0",
"react": ">=16.8.0 <20.0.0",
"react-dom": ">=16.8.0 <20.0.0"
}
},
"node_modules/@stripe/stripe-js": {
"version": "8.6.1",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.6.1.tgz",
"integrity": "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.16"
}
},
"node_modules/@supabase/auth-js": {
"version": "2.90.1",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.90.1.tgz",
"integrity": "sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.90.1",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.90.1.tgz",
"integrity": "sha512-x9mV9dF1Lam9qL3zlpP6mSM5C9iqMPtF5B/tU1Jj/F0ufX5mjDf9ghVBaErVxmrQJRL4+iMKWKY2GnODkpS8tw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "2.90.1",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.90.1.tgz",
"integrity": "sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.90.1",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.90.1.tgz",
"integrity": "sha512-PWbnEMkcQRuor8jhObp4+Snufkq8C6fBp+MchVp2qBPY1NXk/c3Iv3YyiFYVzo0Dzuw4nAlT4+ahuPggy4r32w==",
"license": "MIT",
"dependencies": {
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"tslib": "2.8.1",
"ws": "^8.18.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/ssr": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.8.0.tgz",
"integrity": "sha512-/PKk8kNFSs8QvvJ2vOww1mF5/c5W8y42duYtXvkOSe+yZKRgTTZywYG2l41pjhNomqESZCpZtXuWmYjFRMV+dw==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.2"
},
"peerDependencies": {
"@supabase/supabase-js": "^2.76.1"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.90.1",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.90.1.tgz",
"integrity": "sha512-GHY+Ps/K/RBfRj7kwx+iVf2HIdqOS43rM2iDOIDpapyUnGA9CCBFzFV/XvfzznGykd//z2dkGZhlZZprsVFqGg==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.90.1",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.90.1.tgz",
"integrity": "sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@supabase/auth-js": "2.90.1",
"@supabase/functions-js": "2.90.1",
"@supabase/postgrest-js": "2.90.1",
"@supabase/realtime-js": "2.90.1",
"@supabase/storage-js": "2.90.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1550,12 +1676,18 @@
"version": "20.19.29",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.29.tgz",
"integrity": "sha512-YrT9ArrGaHForBaCNwFjoqJWmn8G1Pr7+BH/vwyLHciA9qT/wSiuOhxGCT50JA5xLvFBd6PIiGkE3afxcPE1nw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/phoenix": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
@@ -1577,6 +1709,15 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz",
@@ -2501,7 +2642,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -2515,7 +2655,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -2581,6 +2720,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2615,6 +2763,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2698,6 +2859,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2786,7 +2957,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -2898,7 +3068,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -2908,7 +3077,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -2946,7 +3114,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -3217,6 +3384,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -3595,7 +3763,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -3656,7 +3823,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -3681,7 +3847,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -3769,7 +3934,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3841,7 +4005,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3870,7 +4033,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -3896,6 +4058,15 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4409,7 +4580,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -4820,7 +4990,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -4839,6 +5008,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.562.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
"integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4853,7 +5031,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5046,7 +5223,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -5056,7 +5232,6 @@
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5345,7 +5520,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -5363,6 +5537,21 @@
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5411,7 +5600,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/reflect.getprototypeof": {
@@ -5739,7 +5927,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -5759,7 +5946,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -5776,7 +5962,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -5795,7 +5980,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -5977,6 +6161,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stripe": {
"version": "20.1.2",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.1.2.tgz",
"integrity": "sha512-qU+lQRRJnTxmyvglYBPE24/IepncmywsAg0GDTsTdP2pb+3e3RdREHJZjKgqCmv0phPxN/nmgNPnIPPH8w0P4A==",
"license": "MIT",
"dependencies": {
"qs": "^6.14.1"
},
"engines": {
"node": ">=16"
},
"peerDependencies": {
"@types/node": ">=16"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -6026,6 +6230,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tailwind-merge": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
@@ -6307,7 +6521,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
@@ -6501,6 +6714,27 @@
"node": ">=0.10.0"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -9,9 +9,18 @@
"lint": "eslint"
},
"dependencies": {
"@stripe/react-stripe-js": "^5.4.1",
"@stripe/stripe-js": "^8.6.1",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.90.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.562.0",
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"stripe": "^20.1.2",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB

8
utils/supabase/client.ts Normal file
View File

@@ -0,0 +1,8 @@
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}

View File

@@ -0,0 +1,60 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// IMPORTANT: Avoid writing any logic between createServerClient and
// getUser(). A simple mistake can make it very hard to debug
// issues with users being logged out.
const {
data: { user },
} = await supabase.auth.getUser()
if (
!user &&
!request.nextUrl.pathname.startsWith('/login') &&
!request.nextUrl.pathname.startsWith('/auth') &&
request.nextUrl.pathname.startsWith('/admin')
) {
// no user, potentially respond by redirecting the user to the login page
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
// IMPORTANT: You *must* return the supabaseResponse object as is. If you're creating a
// new response object with NextResponse.next() make sure to:
// 1. Pass the request in it, like so:
// const myNewResponse = NextResponse.next({ request })
// 2. Copy over the cookies, like so:
// myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())
// 3. Change the myNewResponse object to fit your needs, but make sure to return it!
// If you don't, you can accidentally upend the user's session.
return supabaseResponse
}

29
utils/supabase/server.ts Normal file
View File

@@ -0,0 +1,29 @@
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
)
}