feat: implement merchant dashboard, secure auth, and short_id system

- Added dedicated merchant dashboard with analytics and transactions
- Implemented API Key based authentication for merchants
- Introduced 8-character Short IDs for merchants to use in URLs
- Refactored checkout and payment intent APIs to support multi-gateway
- Enhanced Landing Page with Merchant Portal access and marketing copy
- Fixed Next.js 15 async params build issues
- Updated internal branding to P2CGateway
- Added AyrisTech credits to footer
This commit is contained in:
mstfyldz
2026-01-20 21:58:41 +03:00
parent af09543374
commit 3562e10713
46 changed files with 3505 additions and 414 deletions

View File

@@ -9,11 +9,13 @@ import {
Smartphone, Smartphone,
Calendar Calendar
} from 'lucide-react'; } from 'lucide-react';
import { supabaseAdmin } from '@/lib/supabase'; import { supabaseAdmin } from '@/lib/supabase-admin';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
import { tr } from 'date-fns/locale'; import { tr } from 'date-fns/locale';
import AnalyticsBarChart from '@/components/admin/AnalyticsBarChart';
import QueryRangeSelector from '@/components/admin/QueryRangeSelector';
async function getAnalyticsData() { async function getAnalyticsData(rangeDays: number = 12) {
const { data: transactions, error } = await supabaseAdmin const { data: transactions, error } = await supabaseAdmin
.from('transactions') .from('transactions')
.select('*') .select('*')
@@ -25,10 +27,9 @@ async function getAnalyticsData() {
const totalRevenue = successfulTransactions.reduce((acc, t) => acc + Number(t.amount), 0); const totalRevenue = successfulTransactions.reduce((acc, t) => acc + Number(t.amount), 0);
const avgOrderValue = successfulTransactions.length > 0 ? totalRevenue / successfulTransactions.length : 0; const avgOrderValue = successfulTransactions.length > 0 ? totalRevenue / successfulTransactions.length : 0;
// Monthly data for chart (grouped by month or last 12 periods) // Monthly data for chart (grouped by month or last N periods)
// To keep it simple and meaningful, let's show last 12 days for "Gelir Trendi" const lastPeriods = Array.from({ length: rangeDays }, (_, i) => {
const last12Periods = Array.from({ length: 12 }, (_, i) => { const d = subDays(new Date(), (rangeDays - 1) - i);
const d = subDays(new Date(), 11 - i);
return { return {
date: d.toISOString().split('T')[0], date: d.toISOString().split('T')[0],
label: format(d, 'd MMM', { locale: tr }), label: format(d, 'd MMM', { locale: tr }),
@@ -38,7 +39,7 @@ async function getAnalyticsData() {
successfulTransactions.forEach(t => { successfulTransactions.forEach(t => {
const dateStr = new Date(t.created_at).toISOString().split('T')[0]; const dateStr = new Date(t.created_at).toISOString().split('T')[0];
const periodMatch = last12Periods.find(p => p.date === dateStr); const periodMatch = lastPeriods.find(p => p.date === dateStr);
if (periodMatch) { if (periodMatch) {
periodMatch.amount += Number(t.amount); periodMatch.amount += Number(t.amount);
} }
@@ -47,14 +48,18 @@ async function getAnalyticsData() {
return { return {
totalRevenue, totalRevenue,
avgOrderValue, avgOrderValue,
chartData: last12Periods, chartData: lastPeriods,
totalCount: transactions.length, totalCount: transactions.length,
successCount: successfulTransactions.length, successCount: successfulTransactions.length,
}; };
} }
export default async function AnalyticsPage() { export default async function AnalyticsPage(props: {
const data = await getAnalyticsData(); searchParams: Promise<{ range?: string }>;
}) {
const searchParams = await props.searchParams;
const range = searchParams.range ? parseInt(searchParams.range) : 12;
const data = await getAnalyticsData(range);
if (!data) return <div className="p-10 font-black text-gray-400 uppercase tracking-[0.2em] animate-pulse">Veriler hazırlanıyor...</div>; if (!data) return <div className="p-10 font-black text-gray-400 uppercase tracking-[0.2em] animate-pulse">Veriler hazırlanıyor...</div>;
@@ -76,10 +81,7 @@ export default async function AnalyticsPage() {
<p className="text-sm text-gray-400 font-bold uppercase tracking-widest mt-2">Sistem performans verileri</p> <p className="text-sm text-gray-400 font-bold uppercase tracking-widest mt-2">Sistem performans verileri</p>
</div> </div>
<div className="flex items-center gap-4"> <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"> <QueryRangeSelector />
<Calendar size={18} className="text-gray-300" />
Son 30 Gün
</button>
</div> </div>
</div> </div>
@@ -113,25 +115,7 @@ export default async function AnalyticsPage() {
</div> </div>
</div> </div>
<div className="h-72 flex items-end justify-between gap-4"> <AnalyticsBarChart data={data.chartData} />
{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>
</div> </div>
@@ -172,6 +156,5 @@ export default async function AnalyticsPage() {
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -8,12 +8,15 @@ import {
MoreHorizontal, MoreHorizontal,
ArrowUpRight ArrowUpRight
} from 'lucide-react'; } from 'lucide-react';
import { supabaseAdmin } from '@/lib/supabase'; import { supabaseAdmin } from '@/lib/supabase-admin';
async function getCustomers() { import CustomerSearch from '@/components/admin/CustomerSearch';
async function getFilteredCustomers(queryText?: string) {
const { data: transactions, error } = await supabaseAdmin const { data: transactions, error } = await supabaseAdmin
.from('transactions') .from('transactions')
.select('*'); .select('*')
.order('created_at', { ascending: false });
if (error || !transactions) return null; if (error || !transactions) return null;
@@ -21,7 +24,10 @@ async function getCustomers() {
const customerMap = new Map(); const customerMap = new Map();
transactions.forEach(t => { transactions.forEach(t => {
const key = t.customer_name || t.customer_phone || 'Unknown'; // We use a combination of name and phone as a key if possible,
// fallback to whichever is available
const key = (t.customer_phone || t.customer_name || 'Unknown').toLowerCase().trim();
if (!customerMap.has(key)) { if (!customerMap.has(key)) {
customerMap.set(key, { customerMap.set(key, {
id: t.id, id: t.id,
@@ -29,27 +35,45 @@ async function getCustomers() {
phone: t.customer_phone || 'Telefon Yok', phone: t.customer_phone || 'Telefon Yok',
spent: 0, spent: 0,
orders: 0, orders: 0,
lastOrder: t.created_at,
status: 'New' status: 'New'
}); });
} }
const c = customerMap.get(key); const c = customerMap.get(key);
c.orders += 1; c.orders += 1;
if (t.status === 'succeeded') { if (t.status === 'succeeded') {
c.spent += Number(t.amount); c.spent += Number(t.amount);
} }
// Update last order date if this transaction is newer
if (new Date(t.created_at) > new Date(c.lastOrder)) {
c.lastOrder = t.created_at;
}
}); });
const customers = Array.from(customerMap.values()).map(c => { let customers = Array.from(customerMap.values()).map(c => {
if (c.orders > 5) c.status = 'High Value'; if (c.orders > 5 && c.spent > 1000) c.status = 'High Value';
else if (c.orders > 1) c.status = 'Active'; else if (c.orders > 1) c.status = 'Active';
return c; return c;
}); });
// Client-side search (since customers are derived)
if (queryText) {
const q = queryText.toLowerCase();
customers = customers.filter(c =>
c.name.toLowerCase().includes(q) ||
c.phone.toLowerCase().includes(q)
);
}
return customers; return customers;
} }
export default async function CustomersPage() { export default async function CustomersPage(props: {
const customers = await getCustomers(); searchParams: Promise<{ q?: string }>;
}) {
const searchParams = await props.searchParams;
const customers = await getFilteredCustomers(searchParams.q);
if (!customers) return <div className="p-10 font-black text-gray-400 uppercase tracking-widest animate-pulse">Müşteriler yükleniyor...</div>; if (!customers) return <div className="p-10 font-black text-gray-400 uppercase tracking-widest animate-pulse">Müşteriler yükleniyor...</div>;
@@ -61,21 +85,21 @@ export default async function CustomersPage() {
<h1 className="text-3xl font-black text-gray-900 tracking-tight">Müşteriler</h1> <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> <p className="text-sm text-gray-400 font-bold uppercase tracking-widest mt-2">Müşteri portföyünüzü yönetin</p>
</div> </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"> <div className="p-3 bg-blue-50/50 rounded-2xl border border-blue-100/50 flex items-center gap-3">
<Plus size={18} /> <div className="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></div>
Yeni Müşteri Ekle <span className="text-[10px] font-black text-blue-600 uppercase tracking-widest">Canlı Veritabanı Bağlantısı</span>
</button> </div>
</div> </div>
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <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="bg-white p-10 rounded-[40px] border border-gray-100 shadow-sm flex items-center gap-8 group hover:border-blue-500 transition-colors">
<div className="w-16 h-16 bg-blue-50 rounded-[20px] flex items-center justify-center text-blue-600"> <div className="w-16 h-16 bg-blue-50 rounded-[20px] flex items-center justify-center text-blue-600 group-hover:scale-110 transition-transform">
<Users size={32} /> <Users size={32} />
</div> </div>
<div> <div>
<p className="text-3xl font-black text-gray-900">{customers.length.toLocaleString('tr-TR')}</p> <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> <p className="text-[10px] text-gray-400 font-black uppercase tracking-widest mt-1">Sorgulanan Müşteri</p>
</div> </div>
</div> </div>
<div className="bg-white p-10 rounded-[40px] border border-gray-100 shadow-sm flex items-center gap-8"> <div className="bg-white p-10 rounded-[40px] border border-gray-100 shadow-sm flex items-center gap-8">
@@ -83,8 +107,8 @@ export default async function CustomersPage() {
<ArrowUpRight size={32} /> <ArrowUpRight size={32} />
</div> </div>
<div> <div>
<p className="text-3xl font-black text-gray-900">Gerçek</p> <p className="text-3xl font-black text-gray-900">%{((customers.filter(c => c.status === 'High Value' || c.status === 'Active').length / (customers.length || 1)) * 100).toFixed(0)}</p>
<p className="text-[10px] text-gray-400 font-black uppercase tracking-widest mt-1">Canlı Veri</p> <p className="text-[10px] text-gray-400 font-black uppercase tracking-widest mt-1">Bağlılık Oranı</p>
</div> </div>
</div> </div>
<div className="bg-white p-10 rounded-[40px] border border-gray-100 shadow-sm flex items-center gap-8"> <div className="bg-white p-10 rounded-[40px] border border-gray-100 shadow-sm flex items-center gap-8">
@@ -93,7 +117,7 @@ export default async function CustomersPage() {
</div> </div>
<div> <div>
<p className="text-3xl font-black text-gray-900">{customers.filter(c => c.phone !== 'Telefon Yok').length}</p> <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> <p className="text-[10px] text-gray-400 font-black uppercase tracking-widest mt-1">İletişim Bilgili</p>
</div> </div>
</div> </div>
</div> </div>
@@ -101,17 +125,10 @@ export default async function CustomersPage() {
{/* List */} {/* List */}
<div className="bg-white rounded-[40px] border border-gray-100 shadow-sm overflow-hidden"> <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="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"> <CustomerSearch />
<Search className="absolute left-6 top-1/2 -translate-y-1/2 text-gray-300" size={20} /> <div className="bg-gray-50 px-6 py-4 rounded-2xl">
<input <span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Sıralama: En Son Ödeme</span>
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> </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>
<div className="overflow-x-auto text-sans tracking-tight"> <div className="overflow-x-auto text-sans tracking-tight">
@@ -122,7 +139,7 @@ export default async function CustomersPage() {
<th className="px-10 py-8">Segment</th> <th className="px-10 py-8">Segment</th>
<th className="px-10 py-8">Sipariş</th> <th className="px-10 py-8">Sipariş</th>
<th className="px-10 py-8">Toplam Harcama</th> <th className="px-10 py-8">Toplam Harcama</th>
<th className="px-10 py-8 text-right">Aksiyonlar</th> <th className="px-10 py-8 text-right">Durum</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-50"> <tbody className="divide-y divide-gray-50">
@@ -130,7 +147,7 @@ export default async function CustomersPage() {
<tr key={i} className="group hover:bg-gray-50/50 transition-colors"> <tr key={i} className="group hover:bg-gray-50/50 transition-colors">
<td className="px-10 py-10"> <td className="px-10 py-10">
<div className="flex items-center gap-5"> <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"> <div className="w-14 h-14 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center font-black text-sm uppercase tracking-tighter group-hover:bg-blue-600 group-hover:text-white transition-colors">
{customer.name.slice(0, 2).toUpperCase()} {customer.name.slice(0, 2).toUpperCase()}
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
@@ -141,7 +158,7 @@ export default async function CustomersPage() {
</td> </td>
<td className="px-10 py-10"> <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' : <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 === 'High Value' ? 'bg-blue-600 text-white' :
customer.status === 'New' ? 'bg-indigo-50 text-indigo-600' : customer.status === 'New' ? 'bg-indigo-50 text-indigo-600' :
'bg-gray-50 text-gray-400' 'bg-gray-50 text-gray-400'
}`}> }`}>
@@ -151,7 +168,7 @@ export default async function CustomersPage() {
</span> </span>
</td> </td>
<td className="px-10 py-10"> <td className="px-10 py-10">
<span className="text-sm font-black text-gray-900">{customer.orders}</span> <span className="text-sm font-black text-gray-900">{customer.orders} İşlem</span>
</td> </td>
<td className="px-10 py-10"> <td className="px-10 py-10">
<span className="text-sm font-black text-gray-900"> <span className="text-sm font-black text-gray-900">
@@ -160,12 +177,12 @@ export default async function CustomersPage() {
</td> </td>
<td className="px-10 py-10 text-right"> <td className="px-10 py-10 text-right">
<div className="flex items-center justify-end gap-3"> <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"> <div className="flex flex-col items-end">
<Phone size={18} /> <span className="text-[10px] font-black text-gray-900 uppercase tracking-widest">Son İşlem</span>
</button> <span className="text-[10px] text-gray-400 font-bold mt-1 uppercase">
<button className="p-3 bg-gray-50 text-gray-400 rounded-xl hover:text-gray-900 hover:bg-gray-100 transition"> {new Date(customer.lastOrder).toLocaleDateString('tr-TR')}
<MoreHorizontal size={18} /> </span>
</button> </div>
</div> </div>
</td> </td>
</tr> </tr>
@@ -173,6 +190,12 @@ export default async function CustomersPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
{customers.length === 0 && (
<div className="p-20 text-center">
<Users className="w-12 h-12 text-gray-200 mx-auto mb-4" />
<p className="text-sm font-black text-gray-400 uppercase tracking-widest">Müşteri bulunamadı</p>
</div>
)}
</div> </div>
</div> </div>
); );

184
app/admin/docs/page.tsx Normal file
View File

@@ -0,0 +1,184 @@
'use client';
import React from 'react';
import {
Code2,
Terminal,
Globe,
Webhook,
Copy,
Check,
ArrowRight,
Zap,
ShieldCheck,
MessageSquare
} from 'lucide-react';
export default function DocumentationPage() {
const [copied, setCopied] = React.useState<string | null>(null);
const copyToClipboard = (text: string, id: string) => {
navigator.clipboard.writeText(text);
setCopied(id);
setTimeout(() => setCopied(null), 2000);
};
const checkoutUrlCode = `https://p2cgateway.com/checkout?merchant_id=YOUR_MERCHANT_ID&amount=100&currency=TRY&ref_id=ORDER_123&callback_url=https://yoursite.com/success`;
return (
<div className="max-w-5xl mx-auto space-y-12 pb-20 animate-in fade-in slide-in-from-bottom-4 duration-700">
{/* Header */}
<div>
<h1 className="text-4xl font-black text-gray-900 tracking-tight">API Dokümantasyonu</h1>
<p className="text-gray-400 font-bold uppercase tracking-widest text-xs mt-3 px-1">P2CGateway Entegrasyon Rehberi</p>
</div>
{/* Quick Start Card */}
<div className="bg-blue-600 rounded-[40px] p-12 text-white shadow-xl shadow-blue-100 relative overflow-hidden">
<div className="relative z-10 space-y-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-white/20 rounded-2xl flex items-center justify-center">
<Zap size={24} />
</div>
<h2 className="text-2xl font-black">Hızlı Başlangıç</h2>
</div>
<p className="text-blue-100 text-lg max-w-2xl leading-relaxed font-medium">
P2CGateway'i projenize entegre etmek için sadece bir URL oluşturmanız yeterlidir.
Karmaşık SDK'lar veya kütüphanelerle uğraşmanıza gerek yok.
</p>
</div>
<div className="absolute top-0 right-0 w-64 h-64 bg-white/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl"></div>
</div>
{/* Integration Steps */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Method 1: Checkout Redirect */}
<div className="bg-white rounded-[40px] border border-gray-100 p-10 shadow-sm space-y-8">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-emerald-50 rounded-2xl flex items-center justify-center text-emerald-600">
<Globe size={24} />
</div>
<h3 className="text-xl font-black text-gray-900">1. Ödeme Sayfasına Yönlendirme</h3>
</div>
<p className="text-gray-500 font-medium leading-relaxed">
Müşterinizi ödeme yapması için aşağıdaki URL yapısını kullanarak P2CGateway checkout sayfasına yönlendirin.
</p>
<div className="space-y-4">
<div className="bg-gray-50 p-6 rounded-3xl border border-gray-100 relative group">
<code className="text-xs font-mono text-gray-600 break-all leading-relaxed">
{checkoutUrlCode}
</code>
<button
onClick={() => copyToClipboard(checkoutUrlCode, 'url')}
className="absolute right-4 top-4 p-2 bg-white rounded-xl shadow-sm border border-gray-100 opacity-0 group-hover:opacity-100 transition-opacity"
>
{copied === 'url' ? <Check size={16} className="text-emerald-500" /> : <Copy size={16} className="text-gray-400" />}
</button>
</div>
</div>
<div className="space-y-4">
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest pl-2">Parametreler</h4>
<div className="space-y-3">
{[
{ key: 'merchant_id', desc: 'Firma ID\'niz (Firmalar sayfasından alabilirsiniz)' },
{ key: 'amount', desc: 'Ödeme tutarı (Örn: 100.00)' },
{ key: 'currency', desc: 'Para birimi (TRY, USD, EUR)' },
{ key: 'ref_id', desc: 'Sizin sisteminizdeki sipariş numarası' },
{ key: 'callback_url', desc: 'Ödeme sonrası yönlendirilecek adres' },
].map((p) => (
<div key={p.key} className="flex items-start gap-4 p-4 rounded-2xl hover:bg-gray-50 transition-colors">
<span className="text-xs font-mono text-blue-600 font-bold bg-blue-50 px-2 py-1 rounded-md">{p.key}</span>
<span className="text-xs text-gray-500 font-medium">{p.desc}</span>
</div>
))}
</div>
</div>
</div>
{/* Method 2: Webhooks */}
<div className="bg-white rounded-[40px] border border-gray-100 p-10 shadow-sm space-y-8">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-purple-50 rounded-2xl flex items-center justify-center text-purple-600">
<Webhook size={24} />
</div>
<h3 className="text-xl font-black text-gray-900">2. Webhook Bildirimleri</h3>
</div>
<p className="text-gray-500 font-medium leading-relaxed">
Ödeme tamamlandığında sistemimiz otomatik olarak firmanıza tanımlı olan Webhook URL'ine bir POST isteği gönderir.
</p>
<div className="bg-gray-900 rounded-3xl p-8 space-y-4">
<div className="flex items-center justify-between">
<span className="text-[10px] font-black text-gray-500 uppercase tracking-widest">JSON Payload Örneği</span>
<div className="flex gap-2">
<div className="w-2 h-2 rounded-full bg-red-500"></div>
<div className="w-2 h-2 rounded-full bg-yellow-500"></div>
<div className="w-2 h-2 rounded-full bg-green-500"></div>
</div>
</div>
<pre className="text-xs font-mono text-blue-400 overflow-x-auto leading-relaxed">
{`{
"status": "succeeded",
"transaction_id": "tx_821...",
"ref_id": "ORDER-123",
"amount": 100.00,
"currency": "TRY",
"customer": {
"name": "Ahmet Yılmaz",
"phone": "555..."
}
}`}
</pre>
</div>
<div className="p-6 bg-orange-50 rounded-3xl border border-orange-100 space-y-3">
<h4 className="text-orange-700 font-black text-xs uppercase tracking-widest flex items-center gap-2">
<ShieldCheck size={14} /> Güvenlik Notu
</h4>
<p className="text-orange-600/80 text-xs font-medium leading-normal">
Webhook isteklerinin P2CGateway'den geldiğini doğrulamak için API Key'inizi HTTP başlığında (X-P2C-Signature) kontrol etmelisiniz.
</p>
</div>
</div>
</div>
{/* API Resources Section */}
<div className="bg-white rounded-[40px] border border-gray-100 p-10 shadow-sm">
<div className="flex items-center gap-4 mb-10">
<div className="w-12 h-12 bg-blue-50 rounded-2xl flex items-center justify-center text-blue-600">
<Terminal size={24} />
</div>
<h3 className="text-2xl font-black text-gray-900">Geliştirici Kaynakları</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<button className="p-8 bg-gray-50 rounded-[32px] text-left hover:bg-blue-50 group transition-all">
<div className="w-10 h-10 bg-white rounded-xl flex items-center justify-center text-gray-400 group-hover:text-blue-600 group-hover:bg-blue-600 group-hover:text-white transition-all mb-4">
<Code2 size={20} />
</div>
<h4 className="font-black text-gray-900 mb-2">SDK & Kütüphaneler</h4>
<p className="text-xs text-gray-400 font-bold uppercase tracking-widest">Çok Yakında</p>
</button>
<button className="p-8 bg-gray-50 rounded-[32px] text-left hover:bg-emerald-50 group transition-all">
<div className="w-10 h-10 bg-white rounded-xl flex items-center justify-center text-gray-400 group-hover:text-emerald-600 group-hover:bg-emerald-600 group-hover:text-white transition-all mb-4">
<Globe size={20} />
</div>
<h4 className="font-black text-gray-900 mb-2">API Referansı</h4>
<p className="text-xs text-gray-400 font-bold uppercase tracking-widest">Görüntüle</p>
</button>
<button className="p-8 bg-gray-50 rounded-[32px] text-left hover:bg-purple-50 group transition-all">
<div className="w-10 h-10 bg-white rounded-xl flex items-center justify-center text-gray-400 group-hover:text-purple-600 group-hover:bg-purple-600 group-hover:text-white transition-all mb-4">
<MessageSquare size={20} />
</div>
<h4 className="font-black text-gray-900 mb-2">Teknik Destek</h4>
<p className="text-xs text-gray-400 font-bold uppercase tracking-widest">Geliştirici Topluluğu</p>
</button>
</div>
</div>
</div>
);
}

View File

@@ -14,7 +14,9 @@ import {
Bell, Bell,
MessageSquare, MessageSquare,
ChevronDown, ChevronDown,
Wallet Wallet,
Building2,
Code2
} from 'lucide-react'; } from 'lucide-react';
import Image from 'next/image'; import Image from 'next/image';
import { createClient } from '@/utils/supabase/client'; // Assuming a client-side Supabase client utility import { createClient } from '@/utils/supabase/client'; // Assuming a client-side Supabase client utility
@@ -36,9 +38,11 @@ export default function AdminLayout({
const navItems = [ const navItems = [
{ label: 'Genel Bakış', icon: LayoutDashboard, href: '/admin' }, { label: 'Genel Bakış', icon: LayoutDashboard, href: '/admin' },
{ label: 'Firmalar', icon: Building2, href: '/admin/merchants' },
{ label: 'İşlemler', icon: CreditCard, href: '/admin/transactions' }, { label: 'İşlemler', icon: CreditCard, href: '/admin/transactions' },
{ label: 'Müşteriler', icon: Users, href: '/admin/customers' }, { label: 'Müşteriler', icon: Users, href: '/admin/customers' },
{ label: 'Analizler', icon: BarChart3, href: '/admin/analytics' }, { label: 'Analizler', icon: BarChart3, href: '/admin/analytics' },
{ label: 'Dokümantasyon', icon: Code2, href: '/admin/docs' },
{ label: 'Ayarlar', icon: Settings, href: '/admin/settings' }, { label: 'Ayarlar', icon: Settings, href: '/admin/settings' },
]; ];
@@ -51,8 +55,8 @@ export default function AdminLayout({
<Wallet size={24} /> <Wallet size={24} />
</div> </div>
<div> <div>
<h1 className="font-black text-gray-900 leading-tight">froyd Admin</h1> <h1 className="font-black text-gray-900 leading-tight text-lg">P2CGateway <span className="text-blue-600">Admin</span></h1>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Yönetim Paneli</p> <p className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Merkezi Yönetim Paneli</p>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,222 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
ArrowLeft,
Building2,
Globe,
CheckCircle2,
Loader2,
ShieldCheck,
Smartphone,
CreditCard
} from 'lucide-react';
import Link from 'next/link';
export default function NewMerchantPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [name, setName] = useState('');
const [webhookUrl, setWebhookUrl] = useState('');
const [paymentProvider, setPaymentProvider] = useState('stripe');
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const response = await fetch('/api/merchants', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
webhook_url: webhookUrl,
payment_provider: paymentProvider
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Firma eklenemedi.');
}
setSuccess(true);
setTimeout(() => {
router.push('/admin/merchants');
router.refresh();
}, 2000);
} catch (err: any) {
alert(err.message || 'Firma eklenirken bir hata oluştu.');
} finally {
setIsLoading(false);
}
};
if (success) {
return (
<div className="min-h-[80vh] flex items-center justify-center">
<div className="text-center space-y-6 animate-in fade-in zoom-in duration-500">
<div className="w-24 h-24 bg-emerald-50 rounded-[40px] flex items-center justify-center text-emerald-500 mx-auto shadow-lg shadow-emerald-100">
<CheckCircle2 size={48} />
</div>
<div className="space-y-2">
<h2 className="text-3xl font-black text-gray-900">Firma Başarıyla Oluşturuldu!</h2>
<p className="text-gray-500 font-medium text-lg">Yönlendiriliyorsunuz...</p>
</div>
</div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto space-y-12 pb-20">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-6">
<Link
href="/admin/merchants"
className="w-12 h-12 bg-white rounded-2xl border border-gray-100 flex items-center justify-center text-gray-400 hover:text-gray-900 hover:shadow-md transition-all group"
>
<ArrowLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
</Link>
<div>
<h1 className="text-3xl font-black text-gray-900 tracking-tight">Yeni Firma Kaydı</h1>
<p className="text-gray-400 font-bold uppercase tracking-widest text-[10px] mt-1">Sisteme yeni bir işletme entegre edin</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12">
{/* Form Column */}
<div className="lg:col-span-2 space-y-8">
<form onSubmit={handleSubmit} className="bg-white rounded-[40px] border border-gray-100 p-10 shadow-sm space-y-8">
{/* Name Input */}
<div className="space-y-3">
<label className="text-[11px] font-black text-gray-400 uppercase tracking-widest ml-1">İşletme / Firma Adı</label>
<div className="relative group">
<div className="absolute left-6 top-1/2 -translate-y-1/2 text-gray-300 group-focus-within:text-blue-500 transition-colors">
<Building2 size={20} />
</div>
<input
required
type="text"
placeholder="Örn: Global Teknoloji A.Ş."
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full pl-16 pr-8 py-5 bg-gray-50 border-2 border-transparent focus:border-blue-100 focus:bg-white rounded-3xl text-base font-bold text-gray-900 outline-none transition-all placeholder:text-gray-300"
/>
</div>
</div>
{/* Provider Selection */}
<div className="space-y-4">
<label className="text-[11px] font-black text-gray-400 uppercase tracking-widest ml-1">Ödeme Altyapısı (Gateway)</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{[
{ id: 'stripe', name: 'Stripe', icon: CreditCard, desc: 'Global kart ödemeleri' },
{ id: 'cryptomus', name: 'Cryptomus', icon: Globe, desc: 'Kripto Para' },
{ id: 'nuvei', name: 'Nuvei', icon: ShieldCheck, desc: 'E-commerce Experts' },
{ id: 'paykings', name: 'PayKings', icon: ShieldCheck, desc: 'High Risk Specialist' },
{ id: 'seurionpay', name: 'SecurionPay', icon: CreditCard, desc: 'Simple Payments' },
].map((p) => (
<button
key={p.id}
type="button"
onClick={() => setPaymentProvider(p.id)}
className={`p-6 rounded-3xl border-2 text-left transition-all space-y-2 ${paymentProvider === p.id
? 'border-blue-500 bg-blue-50/50'
: 'border-gray-100 bg-gray-50/30 hover:bg-gray-50'}`}
>
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${paymentProvider === p.id ? 'bg-blue-600 text-white' : 'bg-white text-gray-400 border border-gray-100'}`}>
<p.icon size={20} />
</div>
<div>
<p className="font-black text-sm text-gray-900 leading-tight">{p.name}</p>
<p className="text-[10px] text-gray-400 font-bold uppercase mt-1">{p.desc}</p>
</div>
</button>
))}
</div>
</div>
{/* Webhook Input */}
<div className="space-y-3">
<label className="text-[11px] font-black text-gray-400 uppercase tracking-widest ml-1">Geri Dönüş (Webhook) URL</label>
<div className="relative group">
<div className="absolute left-6 top-1/2 -translate-y-1/2 text-gray-300 group-focus-within:text-blue-500 transition-colors">
<Globe size={20} />
</div>
<input
type="url"
placeholder="https://firma.com/api/callback"
value={webhookUrl}
onChange={(e) => setWebhookUrl(e.target.value)}
className="w-full pl-16 pr-8 py-5 bg-gray-50 border-2 border-transparent focus:border-blue-100 focus:bg-white rounded-3xl text-base font-bold text-gray-900 outline-none transition-all placeholder:text-gray-300"
/>
</div>
<p className="text-[11px] text-gray-400 font-medium leading-relaxed px-1">
Ödeme sonuçlarını gerçek zamanlı almak için firmanın API noktasını buraya girebilirsiniz. (Opsiyonel)
</p>
</div>
{/* Submit Button */}
<div className="pt-4">
<button
type="submit"
disabled={isLoading || !name}
className="w-full bg-[#2563EB] text-white py-6 rounded-3xl font-black text-lg hover:bg-blue-700 transition shadow-xl shadow-blue-100 disabled:opacity-50 disabled:shadow-none flex items-center justify-center gap-3 active:scale-[0.98]"
>
{isLoading ? (
<Loader2 className="animate-spin" size={24} />
) : (
<>
<span>Firmayı Kaydet ve Ödeme Almaya Başla</span>
</>
)}
</button>
</div>
</form>
</div>
{/* Info Column */}
<div className="space-y-6">
<div className="bg-blue-600 rounded-[40px] p-8 text-white shadow-xl shadow-blue-100">
<h4 className="font-black text-xl mb-4">Hızlı Başlangıç</h4>
<ul className="space-y-6">
<li className="flex gap-4">
<div className="w-8 h-8 bg-white/20 rounded-lg flex items-center justify-center shrink-0">
<CheckCircle2 size={16} />
</div>
<p className="text-sm font-medium leading-normal opacity-90">Firma ismi sisteme özel bir ID ile kaydedilir.</p>
</li>
<li className="flex gap-4">
<div className="w-8 h-8 bg-white/20 rounded-lg flex items-center justify-center shrink-0">
<Smartphone size={16} />
</div>
<p className="text-sm font-medium leading-normal opacity-90">Kayıt sonrası hemen ödeme linki oluşturulur.</p>
</li>
<li className="flex gap-4">
<div className="w-8 h-8 bg-white/20 rounded-lg flex items-center justify-center shrink-0">
<ShieldCheck size={16} />
</div>
<p className="text-sm font-medium leading-normal opacity-90">Tüm işlemler 256-bit SSL ile korunur.</p>
</li>
</ul>
</div>
<div className="bg-white rounded-[40px] border border-gray-100 p-8 space-y-4">
<div className="flex items-center gap-3 text-gray-900">
<CreditCard size={20} className="text-blue-600" />
<h4 className="font-black">Desteklenen Kartlar</h4>
</div>
<p className="text-xs text-gray-400 font-bold leading-relaxed">
Visa, Mastercard, Troy ve tüm yerel banka kartları ile ödeme alabilirsiniz.
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,349 @@
'use client';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import {
Plus,
Building2,
Copy,
ExternalLink,
MoreVertical,
Globe,
Check,
Pencil,
Trash2,
X
} from 'lucide-react';
export default function MerchantsPage() {
const [merchants, setMerchants] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editingMerchant, setEditingMerchant] = useState<any>(null);
const [isUpdating, setIsUpdating] = useState(false);
useEffect(() => {
fetchMerchants();
}, []);
const fetchMerchants = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/merchants');
const data = await response.json();
if (!response.ok) throw new Error(data.error);
setMerchants(data);
} catch (err) {
console.error('Fetch error:', err);
} finally {
setIsLoading(false);
}
};
const copyToClipboard = (text: string, id: string) => {
navigator.clipboard.writeText(text);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
const handleEditClick = (merchant: any) => {
setEditingMerchant({ ...merchant });
setIsEditModalOpen(true);
};
const handleUpdateMerchant = async (e: React.FormEvent) => {
e.preventDefault();
setIsUpdating(true);
try {
const response = await fetch(`/api/merchants/${editingMerchant.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: editingMerchant.name,
webhook_url: editingMerchant.webhook_url,
payment_provider: editingMerchant.payment_provider,
provider_config: editingMerchant.provider_config
})
});
if (!response.ok) throw new Error('Güncelleme başarısız.');
await fetchMerchants();
setIsEditModalOpen(false);
setEditingMerchant(null);
} catch (err: any) {
alert(err.message);
} finally {
setIsUpdating(false);
}
};
const handleDeleteMerchant = async (id: string) => {
if (!confirm('Bu firmayı silmek istediğinize emin misiniz? Bu işlem geri alınamaz.')) return;
try {
const response = await fetch(`/api/merchants/${id}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Silme işlemi başarısız.');
await fetchMerchants();
} catch (err: any) {
alert(err.message);
}
};
return (
<div className="space-y-8 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 bg-white p-8 rounded-[32px] border border-gray-100 shadow-sm">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-blue-50 rounded-2xl flex items-center justify-center text-blue-600">
<Building2 size={24} />
</div>
<div>
<h2 className="text-xl font-black text-gray-900">Firmalar (Merchants)</h2>
<p className="text-xs text-gray-400 font-bold uppercase tracking-widest mt-0.5">Ödeme alan tüm işletmeler</p>
</div>
</div>
<Link
href="/admin/merchants/new"
className="flex items-center justify-center gap-2 px-6 py-3 bg-[#2563EB] text-white rounded-2xl text-sm font-bold hover:bg-blue-700 transition shadow-lg shadow-blue-100"
>
<Plus size={18} />
Yeni Firma Ekle
</Link>
</div>
{/* Merchant Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6">
{isLoading ? (
<div className="col-span-full flex justify-center py-20">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
) : merchants.map((m) => {
const identifier = m.short_id || m.id;
const paymentLink = typeof window !== 'undefined'
? `${window.location.origin}/checkout?merchant_id=${identifier}&amount=0`
: `https://p2cgateway.com/checkout?merchant_id=${identifier}&amount=0`;
return (
<div key={m.id} className="bg-white rounded-[40px] border border-gray-100 p-8 shadow-sm hover:shadow-md transition-shadow group">
<div className="flex justify-between items-start mb-6">
<div className="flex items-center gap-4">
<div className="w-14 h-14 bg-gray-50 rounded-2xl flex items-center justify-center text-gray-400 font-black text-xl group-hover:bg-blue-50 group-hover:text-blue-600 transition-colors">
{m.name.substring(0, 1).toUpperCase()}
</div>
<div>
<h3 className="text-lg font-black text-gray-900">{m.name}</h3>
<div className="flex items-center gap-2">
<span className="text-[10px] text-blue-600 font-black uppercase tracking-widest">ID: {identifier}</span>
{m.short_id && <span className="px-1.5 py-0.5 bg-blue-50 text-blue-600 text-[8px] font-black rounded border border-blue-100 uppercase tracking-tighter transition-all group-hover:bg-blue-600 group-hover:text-white group-hover:border-blue-700">Short Link Active</span>}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleEditClick(m)}
className="p-2 text-gray-300 hover:text-blue-600 hover:bg-blue-50 rounded-xl transition-all"
title="Düzenle"
>
<Pencil size={18} />
</button>
<button
onClick={() => handleDeleteMerchant(m.id)}
className="p-2 text-gray-300 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all"
title="Sil"
>
<Trash2 size={18} />
</button>
</div>
</div>
<div className="space-y-4">
{/* API Key Section */}
<div className="p-4 bg-gray-50 rounded-2xl space-y-2 border border-transparent hover:border-gray-100 transition-colors">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest pl-1">API Anahtarı (Secret Key)</label>
<div className="flex items-center justify-between gap-3">
<div className="flex-1 bg-white px-4 py-2 rounded-xl text-[11px] font-mono text-gray-400 truncate border border-gray-100/50">
{m.api_key || '••••••••••••••••'}
</div>
<button
onClick={() => copyToClipboard(m.api_key, m.id + '-key')}
className="p-2 text-gray-400 hover:text-blue-600 transition-colors"
>
{copiedId === m.id + '-key' ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
</div>
{/* Webhook Section */}
<div className="p-4 bg-gray-50 rounded-2xl space-y-2 border border-transparent hover:border-gray-100 transition-colors">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest pl-1">Webhook URL</label>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-bold text-gray-600 truncate px-1">{m.webhook_url || 'Ayarlanmamış'}</span>
<Globe size={14} className="text-gray-300 shrink-0" />
</div>
</div>
{/* Payment Link Section */}
<div className="p-4 bg-blue-50/30 rounded-2xl border border-blue-50 space-y-2">
<label className="text-[10px] font-black text-blue-600 uppercase tracking-widest">Ödeme Linki</label>
<div className="flex items-center gap-3">
<div className="flex-1 bg-white px-4 py-2 rounded-xl text-[11px] font-mono text-gray-400 truncate border border-blue-100/30">
{paymentLink}
</div>
<button
onClick={() => copyToClipboard(paymentLink, m.id)}
className="p-2 bg-white text-blue-600 rounded-xl border border-blue-100 hover:bg-blue-600 hover:text-white transition shadow-sm shrink-0"
>
{copiedId === m.id ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
<p className="text-[10px] text-gray-400 font-medium">Bu linki tutar ve referans ekleyerek değiştirebilirsiniz.</p>
</div>
</div>
<div className="mt-8 pt-6 border-t border-gray-50 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Aktif</span>
</div>
<a
href={`/admin/transactions?merchant_id=${m.id}`}
className="flex items-center gap-2 text-xs font-black text-[#2563EB] hover:underline"
>
Tüm İşlemleri Gör
<ExternalLink size={12} />
</a>
<Link
href={`/merchant/${identifier}`}
className="flex items-center gap-2 text-xs font-black text-emerald-600 hover:underline"
>
Firma Paneli
<ExternalLink size={12} />
</Link>
</div>
</div>
);
})}
{!isLoading && merchants.length === 0 && (
<div className="col-span-full p-20 bg-white rounded-[40px] border border-gray-100 text-center space-y-6">
<div className="w-20 h-20 bg-gray-50 rounded-3xl flex items-center justify-center mx-auto text-gray-200">
<Building2 size={40} />
</div>
<div className="max-w-xs mx-auto">
<p className="text-lg font-black text-gray-900">Henüz firma bulunmuyor</p>
<p className="text-gray-400 text-sm mt-1 font-bold">İlk firmanızı ekleyerek ödeme almaya başlayın.</p>
</div>
<Link
href="/admin/merchants/new"
className="inline-flex items-center gap-2 px-8 py-4 bg-gray-900 text-white rounded-2xl text-sm font-black hover:bg-gray-800 transition shadow-xl shadow-gray-200"
>
<Plus size={20} />
Firma Ekleyerek Başlayın
</Link>
</div>
)}
</div>
{/* Edit Modal */}
{isEditModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-6 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-300">
<div className="bg-white w-full max-w-lg rounded-[40px] shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300">
<div className="p-10">
<div className="flex justify-between items-center mb-10">
<div>
<h2 className="text-2xl font-black text-gray-900">Firmayı Düzenle</h2>
<p className="text-xs text-gray-400 font-bold uppercase tracking-widest mt-2 px-1">Firma bilgilerini güncelle</p>
</div>
<button
onClick={() => setIsEditModalOpen(false)}
className="p-3 hover:bg-gray-50 rounded-2xl text-gray-400 transition"
>
<X size={24} />
</button>
</div>
<form onSubmit={handleUpdateMerchant} className="space-y-8">
<div className="space-y-6">
<div className="space-y-3">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-[0.2em] ml-1">Firma Adı</label>
<div className="relative">
<Building2 className="absolute left-5 top-1/2 -translate-y-1/2 text-gray-300" size={20} />
<input
type="text"
required
value={editingMerchant?.name || ''}
onChange={(e) => setEditingMerchant({ ...editingMerchant, name: e.target.value })}
placeholder="Örn: Ayris Teknoloji"
className="w-full pl-14 pr-6 py-4 bg-gray-50 border-2 border-transparent focus:border-blue-500 focus:bg-white rounded-[24px] outline-none transition-all font-bold text-gray-900 placeholder:text-gray-300"
/>
</div>
</div>
<div className="space-y-4">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-[0.2em] ml-1">Ödeme Altyapısı (Gateway)</label>
<div className="grid grid-cols-2 gap-3">
{[
{ id: 'stripe', name: 'Stripe' },
{ id: 'cryptomus', name: 'Cryptomus' },
{ id: 'nuvei', name: 'Nuvei' },
{ id: 'paykings', name: 'PayKings' },
{ id: 'securionpay', name: 'SecurionPay' },
].map((p) => (
<button
key={p.id}
type="button"
onClick={() => setEditingMerchant({ ...editingMerchant, payment_provider: p.id })}
className={`px-4 py-3 rounded-xl border text-xs font-bold transition-all ${editingMerchant?.payment_provider === p.id
? 'border-blue-500 bg-blue-50 text-blue-600'
: 'border-gray-100 bg-gray-50 text-gray-400 hover:bg-gray-100'}`}
>
{p.name}
</button>
))}
</div>
</div>
<div className="space-y-3">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-[0.2em] ml-1">Webhook URL (Geri Dönüş)</label>
<div className="relative">
<Globe className="absolute left-5 top-1/2 -translate-y-1/2 text-gray-300" size={20} />
<input
type="url"
value={editingMerchant?.webhook_url || ''}
onChange={(e) => setEditingMerchant({ ...editingMerchant, webhook_url: e.target.value })}
placeholder="https://siteniz.com/webhook"
className="w-full pl-14 pr-6 py-4 bg-gray-50 border-2 border-transparent focus:border-blue-500 focus:bg-white rounded-[24px] outline-none transition-all font-bold text-gray-900 placeholder:text-gray-300"
/>
</div>
<p className="text-[10px] text-gray-400 font-medium px-1">Ödeme başarılı olduğunda bu adrese bildirim gönderilecektir.</p>
</div>
</div>
<div className="flex gap-4 pt-4">
<button
type="button"
onClick={() => setIsEditModalOpen(false)}
className="flex-1 py-4 text-gray-400 font-black text-sm uppercase tracking-widest hover:text-gray-600 transition"
>
İptal
</button>
<button
type="submit"
disabled={isUpdating}
className="flex-[2] py-4 bg-blue-600 text-white rounded-2xl font-black text-sm uppercase tracking-widest hover:bg-blue-700 transition shadow-xl shadow-blue-100 disabled:opacity-50"
>
{isUpdating ? 'Güncelleniyor...' : 'Değişiklikleri Kaydet'}
</button>
</div>
</form>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { supabaseAdmin } from '@/lib/supabase'; import { supabaseAdmin } from '@/lib/supabase-admin';
import { import {
TrendingUp, TrendingUp,
TrendingDown, TrendingDown,
@@ -11,8 +11,10 @@ import {
import { format } from 'date-fns'; import { format } from 'date-fns';
import { tr } from 'date-fns/locale'; import { tr } from 'date-fns/locale';
import Link from 'next/link'; import Link from 'next/link';
import TransactionChart from '@/components/admin/TransactionChart';
import QueryRangeSelector from '@/components/admin/QueryRangeSelector';
async function getStats() { async function getStats(rangeDays: number = 30) {
const { data: transactions, error } = await supabaseAdmin const { data: transactions, error } = await supabaseAdmin
.from('transactions') .from('transactions')
.select('*') .select('*')
@@ -34,11 +36,11 @@ async function getStats() {
.map(t => t.customer_name || t.customer_phone) .map(t => t.customer_name || t.customer_phone)
).size; ).size;
// Last 30 days chart data // Dynamic chart data based on range
const last30Days = Array.from({ length: 30 }, (_, i) => { const chartData = Array.from({ length: rangeDays }, (_, i) => {
const d = new Date(); const d = new Date();
d.setHours(0, 0, 0, 0); d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() - (29 - i)); d.setDate(d.getDate() - (rangeDays - 1 - i));
return { return {
date: d.toISOString().split('T')[0], date: d.toISOString().split('T')[0],
displayDate: format(d, 'd MMM', { locale: tr }), displayDate: format(d, 'd MMM', { locale: tr }),
@@ -48,7 +50,7 @@ async function getStats() {
successfulTransactions.forEach(t => { successfulTransactions.forEach(t => {
const dateStr = new Date(t.created_at).toISOString().split('T')[0]; const dateStr = new Date(t.created_at).toISOString().split('T')[0];
const dayMatch = last30Days.find(d => d.date === dateStr); const dayMatch = chartData.find(d => d.date === dateStr);
if (dayMatch) { if (dayMatch) {
dayMatch.amount += Number(t.amount); dayMatch.amount += Number(t.amount);
} }
@@ -62,12 +64,16 @@ async function getStats() {
successRate, successRate,
totalCount, totalCount,
uniqueCustomers, uniqueCustomers,
chartData: last30Days chartData
}; };
} }
export default async function AdminDashboard() { export default async function AdminDashboard(props: {
const stats = await getStats(); searchParams: Promise<{ range?: string }>;
}) {
const searchParams = await props.searchParams;
const range = searchParams.range ? parseInt(searchParams.range) : 30;
const stats = await getStats(range);
if (!stats) { 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>; return <div className="p-10 font-bold text-gray-500 font-sans tracking-tight uppercase">Henüz bir işlem verisi bulunamadı.</div>;
@@ -96,163 +102,65 @@ export default async function AdminDashboard() {
</div> </div>
</div> </div>
{/* Total Customers */} {/* Successful Transactions */}
<div className="bg-white p-8 rounded-3xl border border-gray-100 shadow-sm space-y-4"> <div className="bg-white p-8 rounded-3xl border border-gray-100 shadow-sm space-y-4">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<p className="text-sm font-bold text-gray-400 uppercase tracking-wider">Toplam Müşteri</p> <p className="text-sm font-bold text-gray-400 uppercase tracking-wider">İşlem Sayısı</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"> <div className="p-3 bg-emerald-50 rounded-xl text-emerald-600">
<CheckCircle2 size={20} /> <CheckCircle2 size={20} />
</div> </div>
</div> </div>
<div> <div>
<h3 className="text-3xl font-black text-gray-900">{stats.successRate.toFixed(1)}%</h3> <h3 className="text-3xl font-black text-gray-900">{stats.successfulCount}</h3>
<div className="flex items-center gap-1 mt-2 text-emerald-500 font-bold text-xs uppercase tracking-tighter"> <p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest mt-2">Tamamlanan Ödeme</p>
<TrendingUp size={14} />
<span>Optimized <span className="text-gray-400 font-medium lowercase">ödeme dönüşüm oranı</span></span>
</div> </div>
</div> </div>
{/* Conversion 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-orange-50 rounded-xl text-orange-600">
<TrendingUp size={20} />
</div>
</div>
<div>
<h3 className="text-3xl font-black text-gray-900">%{stats.successRate.toFixed(1)}</h3>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest mt-2">{stats.totalCount} Toplam İstek</p>
</div>
</div>
{/* Unique 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">Tekil Müşteri</p>
<div className="p-3 bg-purple-50 rounded-xl text-purple-600">
<Users size={20} />
</div>
</div>
<div>
<h3 className="text-3xl font-black text-gray-900">{stats.uniqueCustomers}</h3>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest mt-2">Farklı Ödeme Kaynağı</p>
</div>
</div> </div>
</div> </div>
{/* Middle Section: Charts */} {/* Middle Section: Charts */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div className="bg-white p-8 rounded-3xl border border-gray-100 shadow-sm">
{/* 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 className="flex justify-between items-center mb-10">
<div> <div>
<h3 className="text-lg font-black text-gray-900 leading-none">İşlem Hacmi</h3> <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> <p className="text-xs text-gray-400 font-bold uppercase tracking-wider mt-2">Son {range} günlük toplam hacim</p>
</div> </div>
<select className="bg-gray-50 border-none rounded-xl text-[10px] font-black uppercase tracking-widest px-4 py-2 outline-none"> <div className="flex flex-col items-end gap-2">
<option>Son 30 Gün</option> <QueryRangeSelector />
<option>Son 7 Gün</option> <p className="text-[9px] text-gray-400 font-bold uppercase tracking-tighter">
</select> {range} günlük veri gösteriliyor
</div> </p>
<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> </div>
<div className="grid grid-cols-2 gap-x-8 gap-y-4 w-full px-4"> <TransactionChart data={stats.chartData} />
<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> </div>
{/* Bottom Section: Recent Transactions Table */} {/* Bottom Section: Recent Transactions Table */}
@@ -280,7 +188,7 @@ export default async function AdminDashboard() {
<tr key={t.id} className="group hover:bg-gray-50/50 transition-colors"> <tr key={t.id} className="group hover:bg-gray-50/50 transition-colors">
<td className="px-10 py-8"> <td className="px-10 py-8">
<div className="flex flex-col"> <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-sm font-black text-gray-900">#{t.stripe_pi_id?.slice(-8).toUpperCase() || 'EXTERNAL'}</span>
<span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider mt-1">{t.id.slice(0, 8)}</span> <span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider mt-1">{t.id.slice(0, 8)}</span>
</div> </div>
</td> </td>

View File

@@ -39,11 +39,11 @@ export default function SettingsPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest pl-2">Mağaza Adı</label> <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" /> <input type="text" defaultValue="P2CGateway" 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>
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest pl-2">Destek E-postası</label> <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" /> <input type="email" defaultValue="support@ayris.dev" 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>
<div className="space-y-3"> <div className="space-y-3">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest pl-2">Para Birimi</label> <label className="text-[10px] font-black text-gray-400 uppercase tracking-widest pl-2">Para Birimi</label>

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { supabaseAdmin } from '@/lib/supabase'; import { supabaseAdmin } from '@/lib/supabase-admin';
import { import {
Search, Search,
Filter, Filter,
@@ -9,37 +9,72 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { tr } from 'date-fns/locale'; import { tr } from 'date-fns/locale';
import TransactionSearch from '@/components/admin/TransactionSearch';
import TransactionStatusFilter from '@/components/admin/TransactionStatusFilter';
async function getTransactions() { async function getTransactions(filters: { merchant_id?: string; q?: string; status?: string }) {
const { data, error } = await supabaseAdmin let query = supabaseAdmin
.from('transactions') .from('transactions')
.select('*') .select('*, merchants(name)')
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });
if (error) return []; if (filters.merchant_id) {
query = query.eq('merchant_id', filters.merchant_id);
}
if (filters.status) {
query = query.eq('status', filters.status);
}
if (filters.q) {
// First, search for merchants matching the name to get their IDs
const { data: matchedMerchants } = await supabaseAdmin
.from('merchants')
.select('id')
.ilike('name', `%${filters.q}%`);
const merchantIds = matchedMerchants?.map(m => m.id) || [];
// Construct OR query parts
let orParts = [
`stripe_pi_id.ilike.%${filters.q}%`,
`source_ref_id.ilike.%${filters.q}%`,
`customer_name.ilike.%${filters.q}%`
];
if (merchantIds.length > 0) {
orParts.push(`merchant_id.in.(${merchantIds.join(',')})`);
}
query = query.or(orParts.join(','));
}
const { data, error } = await query;
if (error) {
console.error('Fetch error:', error);
return [];
}
return data; return data;
} }
export default async function TransactionsPage() { export default async function TransactionsPage(props: {
const transactions = await getTransactions(); searchParams: Promise<{ merchant_id?: string; q?: string; status?: string }>;
}) {
const searchParams = await props.searchParams;
const transactions = await getTransactions({
merchant_id: searchParams.merchant_id,
q: searchParams.q,
status: searchParams.status
});
return ( return (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700"> <div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
{/* Search and Filters Header */} {/* 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 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="flex items-center gap-4 flex-1">
<div className="relative flex-1 max-w-md"> <TransactionSearch />
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" size={20} /> <TransactionStatusFilter />
<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> </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"> <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">
@@ -54,6 +89,7 @@ export default async function TransactionsPage() {
<table className="w-full text-left"> <table className="w-full text-left">
<thead> <thead>
<tr className="bg-gray-50/30 text-gray-400 text-[10px] font-black uppercase tracking-[0.2em] border-b border-gray-50"> <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">Firma</th>
<th className="px-10 py-6">İşlem ID</th> <th className="px-10 py-6">İşlem ID</th>
<th className="px-10 py-6">Referans / Kaynak</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">Tarih & Saat</th>
@@ -63,8 +99,15 @@ export default async function TransactionsPage() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-50"> <tbody className="divide-y divide-gray-50">
{transactions.map((t) => ( {transactions.map((t: any) => (
<tr key={t.id} className="group hover:bg-gray-50/50 transition-colors"> <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-xs font-black text-blue-600 uppercase tracking-wider">
{t.merchants?.name || 'Doğrudan'}
</span>
</div>
</td>
<td className="px-10 py-8"> <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> <span className="text-sm font-black text-gray-900 uppercase">#{t.stripe_pi_id?.slice(-12).toUpperCase() || 'MOCK'}</span>
</td> </td>

View File

@@ -1,50 +1,89 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe'; import { stripe } from '@/lib/stripe';
import { supabaseAdmin } from '@/lib/supabase'; import { supabaseAdmin } from '@/lib/supabase-admin';
import { PaymentProviderFactory } from '@/lib/payment-providers';
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const { amount, currency, ref_id, callback_url, customer_name, customer_phone } = await req.json(); const { amount, currency, ref_id, callback_url, customer_name, customer_phone, merchant_id } = await req.json();
if (!amount || !currency) { if (!amount || !currency || !merchant_id) {
return NextResponse.json( return NextResponse.json(
{ error: 'Tutar ve para birimi zorunludur.' }, { error: 'Tutar, para birimi ve firma ID zorunludur.' },
{ status: 400 } { status: 400 }
); );
} }
const useMock = process.env.NEXT_PUBLIC_USE_MOCK_PAYMENTS === 'true'; // 1. Fetch Merchant to check provider (Support both UUID and Short ID)
let clientSecret = 'mock_secret_' + Math.random().toString(36).substring(7); const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(merchant_id);
let stripeId = 'mock_pi_' + Math.random().toString(36).substring(7);
if (!useMock) { const query = supabaseAdmin
// 1. Create PaymentIntent in Stripe .from('merchants')
const paymentIntent = await stripe.paymentIntents.create({ .select('*');
amount: Math.round(amount * 100), // Stripe uses subunits (e.g., cents)
currency: currency.toLowerCase(), if (isUUID) {
metadata: { query.eq('id', merchant_id);
ref_id, } else {
callback_url, query.eq('short_id', merchant_id);
customer_name,
customer_phone,
},
});
clientSecret = paymentIntent.client_secret!;
stripeId = paymentIntent.id;
} }
// 2. Log transaction in Supabase with 'pending' status const { data: merchant, error: merchantError } = await query.single();
if (merchantError || !merchant) {
return NextResponse.json({ error: 'Firma bulunamadı.' }, { status: 404 });
}
// Use the actual UUID for DB operations
const resolvedMerchantId = merchant.id;
const provider = merchant.payment_provider || 'stripe';
const useMock = process.env.NEXT_PUBLIC_USE_MOCK_PAYMENTS === 'true';
let clientSecret = '';
let providerTxId = '';
let nextAction = 'none';
let redirectUrl = '';
if (useMock) {
clientSecret = 'mock_secret_' + Math.random().toString(36).substring(7);
providerTxId = clientSecret;
} else {
// 2. Use Factory to create intent based on provider
const intent = await PaymentProviderFactory.createIntent(provider, {
amount,
currency,
merchantId: resolvedMerchantId,
refId: ref_id,
customerName: customer_name,
customerPhone: customer_phone,
callbackUrl: callback_url,
providerConfig: merchant.provider_config
});
clientSecret = intent.clientSecret;
providerTxId = intent.providerTxId;
nextAction = intent.nextAction || 'none';
redirectUrl = intent.redirectUrl || '';
}
// 3. Log transaction in Supabase
const { error: dbError } = await supabaseAdmin const { error: dbError } = await supabaseAdmin
.from('transactions') .from('transactions')
.insert({ .insert({
amount, amount,
currency, currency,
status: 'pending', status: 'pending',
stripe_pi_id: stripeId, stripe_pi_id: providerTxId, // We keep using this column for now or we could use provider_tx_id if we updated schema
source_ref_id: ref_id, source_ref_id: ref_id,
customer_name, customer_name,
customer_phone, customer_phone,
callback_url, callback_url,
merchant_id: resolvedMerchantId,
provider: provider,
metadata: {
nextAction,
redirectUrl
}
}); });
if (dbError) { if (dbError) {
@@ -53,6 +92,9 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ return NextResponse.json({
clientSecret: clientSecret, clientSecret: clientSecret,
nextAction,
redirectUrl,
provider
}); });
} catch (err: any) { } catch (err: any) {
console.error('Internal Error:', err); console.error('Internal Error:', err);

View File

@@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabaseAdmin } from '@/lib/supabase-admin';
export async function GET(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const { data, error } = await supabaseAdmin
.from('merchants')
.select('*')
.eq('id', id)
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 404 });
}
return NextResponse.json(data);
} catch (err: any) {
return NextResponse.json(
{ error: `Internal Server Error: ${err.message}` },
{ status: 500 }
);
}
}
export async function PATCH(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const { name, webhook_url, payment_provider, provider_config } = await req.json();
if (!name) {
return NextResponse.json(
{ error: 'Firma adı zorunludur.' },
{ status: 400 }
);
}
const { data, error } = await supabaseAdmin
.from('merchants')
.update({
name,
webhook_url,
payment_provider,
provider_config
})
.eq('id', id)
.select()
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(data);
} catch (err: any) {
return NextResponse.json(
{ error: `Internal Server Error: ${err.message}` },
{ status: 500 }
);
}
}
export async function DELETE(
req: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const { id } = await context.params;
const { error } = await supabaseAdmin
.from('merchants')
.delete()
.eq('id', id);
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ success: true });
} catch (err: any) {
return NextResponse.json(
{ error: `Internal Server Error: ${err.message}` },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabaseAdmin } from '@/lib/supabase-admin';
import { cookies } from 'next/headers';
export async function POST(req: NextRequest) {
try {
const { identifier, apiKey } = await req.json();
if (!identifier || !apiKey) {
return NextResponse.json({ error: 'Eksik bilgi.' }, { status: 400 });
}
// 1. Resolve merchant by ID or short_id
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(identifier);
const query = supabaseAdmin
.from('merchants')
.select('*');
if (isUUID) {
query.eq('id', identifier);
} else {
query.eq('short_id', identifier);
}
const { data: merchant, error } = await query.single();
if (error || !merchant) {
return NextResponse.json({ error: 'Firma bulunamadı.' }, { status: 404 });
}
// 2. Verify API Key
if (merchant.api_key !== apiKey) {
return NextResponse.json({ error: 'Geçersiz anahtar.' }, { status: 401 });
}
// 3. Set Auth Cookie (simplified for now)
// Store the merchant ID in a cookie
const cookieStore = await cookies();
cookieStore.set(`merchant_auth_${merchant.id}`, 'true', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
});
// Also set a temporary short_id link if needed
if (merchant.short_id) {
cookieStore.set(`merchant_auth_${merchant.short_id}`, 'true', {
httpOnly: true,
maxAge: 60 * 60 * 24,
path: '/',
});
}
return NextResponse.json({ success: true });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
export async function POST(req: NextRequest) {
try {
const { identifier } = await req.json();
const cookieStore = await cookies();
// We don't know the exact UUID easily if they provide a short_id,
// but we can try to clear both or just clear the one we know.
// Actually, since we set it for both in the auth route, let's clear common ones.
// A better way: clear all cookies starting with merchant_auth_
// But Next.js cookies API doesn't support listing/clearing by pattern easily.
// So we'll just clear the one for the provided identifier.
cookieStore.delete(`merchant_auth_${identifier}`);
return NextResponse.json({ success: true });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { supabaseAdmin } from '@/lib/supabase-admin';
export async function POST(req: NextRequest) {
try {
const { name, webhook_url, payment_provider, provider_config } = await req.json();
if (!name) {
return NextResponse.json(
{ error: 'Firma adı zorunludur.' },
{ status: 400 }
);
}
// Generate a 8-character short ID (e.g., P2C-A1B2C3)
const generateShortId = () => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
for (let i = 0; i < 8; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
};
const { data, error } = await supabaseAdmin
.from('merchants')
.insert([{
name,
webhook_url,
short_id: generateShortId(),
payment_provider: payment_provider || 'stripe',
provider_config: provider_config || {}
}])
.select()
.single();
if (error) {
console.error('Merchant creation error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(data);
} catch (err: any) {
console.error('Internal Error:', err);
return NextResponse.json(
{ error: `Internal Server Error: ${err.message}` },
{ status: 500 }
);
}
}
export async function GET() {
try {
const { data, error } = await supabaseAdmin
.from('merchants')
.select('*')
.order('created_at', { ascending: false });
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(data);
} catch (err: any) {
return NextResponse.json(
{ error: `Internal Server Error: ${err.message}` },
{ status: 500 }
);
}
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { supabaseAdmin } from '@/lib/supabase'; import { supabaseAdmin } from '@/lib/supabase-admin';
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { supabaseAdmin } from '@/lib/supabase'; import { supabaseAdmin } from '@/lib/supabase-admin';
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe'; import { stripe } from '@/lib/stripe';
import { supabaseAdmin } from '@/lib/supabase'; import { supabaseAdmin } from '@/lib/supabase-admin';
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
@@ -35,13 +35,45 @@ export async function POST(req: NextRequest) {
} }
async function handlePaymentSucceeded(paymentIntent: any) { async function handlePaymentSucceeded(paymentIntent: any) {
const { error } = await supabaseAdmin // 1. Update status in our DB
const { data: transaction, error: updateError } = await supabaseAdmin
.from('transactions') .from('transactions')
.update({ status: 'succeeded' }) .update({ status: 'succeeded' })
.eq('stripe_pi_id', paymentIntent.id); .eq('stripe_pi_id', paymentIntent.id)
.select('*')
.single();
if (error) { if (updateError) {
console.error('Error updating transaction success:', error); console.error('Error updating transaction success:', updateError);
return;
}
// 2. If callback_url exists, notify the merchant (firm)
if (transaction && transaction.callback_url) {
try {
console.log(`Sending callback to: ${transaction.callback_url}`);
const response = await fetch(transaction.callback_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: 'success',
amount: transaction.amount,
currency: transaction.currency,
ref_id: transaction.source_ref_id,
transaction_id: transaction.id,
stripe_id: transaction.stripe_pi_id,
customer_name: transaction.customer_name,
timestamp: new Date().toISOString()
}),
});
if (!response.ok) {
console.warn(`Callback failed with status: ${response.status}`);
}
} catch (err) {
console.error('Error sending callback:', err);
}
} }
} }

View File

@@ -7,7 +7,6 @@ import { useSearchParams } from 'next/navigation';
import CheckoutForm from '@/components/checkout/CheckoutForm'; import CheckoutForm from '@/components/checkout/CheckoutForm';
import MockCheckoutForm from '@/components/checkout/MockCheckoutForm'; import MockCheckoutForm from '@/components/checkout/MockCheckoutForm';
import { Loader2, AlertCircle, ArrowLeft, UserCircle } from 'lucide-react'; import { Loader2, AlertCircle, ArrowLeft, UserCircle } from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link'; // Added Link import import Link from 'next/link'; // Added Link import
function CheckoutContent() { function CheckoutContent() {
@@ -16,8 +15,10 @@ function CheckoutContent() {
const currency = searchParams.get('currency') || 'TL'; const currency = searchParams.get('currency') || 'TL';
const refId = searchParams.get('ref_id') || 'SEC-99231-TX'; const refId = searchParams.get('ref_id') || 'SEC-99231-TX';
const callbackUrl = searchParams.get('callback_url') || '/'; const callbackUrl = searchParams.get('callback_url') || '/';
const merchantId = searchParams.get('merchant_id') || null;
const [clientSecret, setClientSecret] = useState<string | null>(null); const [clientSecret, setClientSecret] = useState<string | null>(null);
const [paymentData, setPaymentData] = useState<any>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isMock = process.env.NEXT_PUBLIC_USE_MOCK_PAYMENTS === 'true'; const isMock = process.env.NEXT_PUBLIC_USE_MOCK_PAYMENTS === 'true';
@@ -31,7 +32,13 @@ function CheckoutContent() {
fetch('/api/create-payment-intent', { fetch('/api/create-payment-intent', {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount, currency, ref_id: refId, callback_url: callbackUrl }), body: JSON.stringify({
amount,
currency,
ref_id: refId,
callback_url: callbackUrl,
merchant_id: merchantId
}),
}) })
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .then((data) => {
@@ -39,6 +46,14 @@ function CheckoutContent() {
setError(data.error); setError(data.error);
} else { } else {
setClientSecret(data.clientSecret); setClientSecret(data.clientSecret);
setPaymentData(data);
// Auto-redirect if it's a redirect action
if (data.nextAction === 'redirect' && data.redirectUrl) {
setTimeout(() => {
window.location.href = data.redirectUrl;
}, 2000); // 2 second delay to show the message
}
} }
}) })
.catch(() => setError('Ödeme başlatılamadı. Lütfen tekrar deneyin.')); .catch(() => setError('Ödeme başlatılamadı. Lütfen tekrar deneyin.'));
@@ -68,7 +83,7 @@ function CheckoutContent() {
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center"> <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 className="w-4 h-4 bg-white rotate-45 transform"></div>
</div> </div>
<span className="font-bold text-gray-900 text-lg tracking-tight">froydPay</span> <span className="font-bold text-gray-900 text-lg tracking-tight">P2CGateway</span>
</div> </div>
<div className="w-8 h-8 bg-orange-100 rounded-full flex items-center justify-center text-orange-500"> <div className="w-8 h-8 bg-orange-100 rounded-full flex items-center justify-center text-orange-500">
<UserCircle size={24} /> <UserCircle size={24} />
@@ -76,37 +91,47 @@ function CheckoutContent() {
</nav> </nav>
{/* Main Content */} {/* 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"> <div className="flex-1 flex flex-col lg:flex-row items-stretch w-full overflow-hidden">
{/* Left Column: Product Info */} {/* Left Column: Product Info (Cover Image) */}
<div className="flex-1 flex flex-col justify-center items-center lg:items-end space-y-8 order-2 lg:order-1"> <div className="flex-1 min-h-[500px] lg:min-h-0 relative group order-2 lg:order-1">
<div className="relative group perspective-1000"> <img
<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" src="/digital_nft_asset.png"
alt="Digital NFT Product" alt="Digital NFT Product"
fill className="absolute inset-0 w-full h-full object-cover"
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> {/* Overlay Gradient */}
<h3 className="text-white text-2xl font-black tracking-tight uppercase">CyberCube #082</h3> <div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent pointer-events-none" />
<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> {/* Content on Image */}
<div className="relative h-full flex flex-col justify-between p-12">
<div className="flex items-center gap-2 bg-white/10 backdrop-blur-md w-fit px-4 py-2 rounded-full border border-white/20">
<span className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
<span className="text-white text-[10px] font-bold uppercase tracking-widest">Premium Dijital Varlık</span>
</div> </div>
{/* Gloss Effect */} <div className="space-y-6">
<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>
<h3 className="text-white text-5xl font-black tracking-tight uppercase leading-none">CyberCube #082</h3>
<p className="text-gray-300 text-lg mt-4 max-w-lg leading-relaxed">P2C ına ömür boyu erişim sağlayan özel, yüksek sadakatli 3D üretken dijital koleksiyon parçası.</p>
</div> </div>
<div className="text-center lg:text-right space-y-2 hidden lg:block"> <div className="pt-8 border-t border-white/10 flex flex-col sm:flex-row sm:items-center gap-8">
<p className="text-gray-400 text-sm font-medium">Satıcı: <span className="text-gray-900 uppercase">Froyd Digital Media INC.</span></p> <div>
<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> <p className="text-gray-400 text-[10px] font-bold uppercase tracking-wider mb-1">Satıcı</p>
<p className="text-white font-medium text-sm">Ayris Digital Media INC.</p>
</div>
<div>
<p className="text-gray-400 text-[10px] font-bold uppercase tracking-wider mb-1">Destek</p>
<p className="text-blue-400 font-medium text-sm hover:underline cursor-pointer">help@ayris.dev</p>
</div>
</div>
</div>
</div> </div>
</div> </div>
{/* Right Column: Payment Form */} {/* Right Column: Payment Form */}
<div className="flex-1 flex flex-col justify-center items-center lg:items-start order-1 lg:order-2"> <div className="flex-1 flex flex-col justify-center items-center lg:items-start order-1 lg:order-2 p-8 lg:p-20">
{!clientSecret ? ( {!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"> <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" /> <Loader2 className="w-10 h-10 text-blue-600 animate-spin mb-4" />
@@ -114,9 +139,26 @@ function CheckoutContent() {
</div> </div>
) : ( ) : (
<div className="w-full"> <div className="w-full">
{isMock ? ( {paymentData?.nextAction === 'redirect' ? (
<div className="p-12 bg-white rounded-[40px] border border-gray-100 shadow-sm text-center space-y-8 animate-in zoom-in duration-500">
<div className="w-20 h-20 bg-blue-50 rounded-3xl flex items-center justify-center mx-auto text-blue-600">
<Loader2 className="animate-spin" size={40} />
</div>
<div className="space-y-3">
<h3 className="text-2xl font-black text-gray-900 leading-tight">Ödeme Sayfasına Yönlendiriliyorsunuz</h3>
<p className="text-gray-400 font-bold uppercase tracking-widest text-[10px]">Lütfen tarayıcınızı kapatmayın</p>
</div>
<p className="text-sm text-gray-400 max-w-xs mx-auto">Sizi güvenli ödeme adımına aktarıyoruz. Bu işlem birkaç saniye sürebilir.</p>
<button
onClick={() => window.location.href = paymentData.redirectUrl}
className="w-full py-5 bg-gray-900 text-white rounded-2xl font-black text-xs uppercase tracking-[0.2em] hover:bg-black transition shadow-xl"
>
Hemen Git
</button>
</div>
) : isMock ? (
<MockCheckoutForm amount={amount} currency={currency} callbackUrl={callbackUrl} clientSecret={clientSecret} refId={refId} /> <MockCheckoutForm amount={amount} currency={currency} callbackUrl={callbackUrl} clientSecret={clientSecret} refId={refId} />
) : ( ) : paymentData?.provider === 'stripe' ? (
<Elements stripe={getStripe()} options={{ clientSecret, appearance: { theme: 'stripe' } }}> <Elements stripe={getStripe()} options={{ clientSecret, appearance: { theme: 'stripe' } }}>
<CheckoutForm <CheckoutForm
amount={amount} amount={amount}
@@ -125,6 +167,12 @@ function CheckoutContent() {
piId={clientSecret.split('_secret')[0]} piId={clientSecret.split('_secret')[0]}
/> />
</Elements> </Elements>
) : (
<div className="p-12 bg-white rounded-[40px] border border-gray-100 shadow-sm text-center">
<AlertCircle className="w-12 h-12 text-blue-600 mx-auto mb-4" />
<h3 className="text-xl font-black text-gray-900">{paymentData?.provider.toUpperCase()} Entegrasyonu</h3>
<p className="text-gray-400 text-sm mt-2 uppercase font-bold tracking-widest">Bu ödeme yöntemi geliştirme aşamasındadır.</p>
</div>
)} )}
<div className="mt-8 flex justify-center lg:justify-start"> <div className="mt-8 flex justify-center lg:justify-start">
@@ -141,7 +189,7 @@ function CheckoutContent() {
{/* Footer */} {/* Footer */}
<footer className="py-12 border-t border-gray-100 text-center"> <footer className="py-12 border-t border-gray-100 text-center">
<p className="text-[#94A3B8] text-[10px] font-medium tracking-tight uppercase"> <p className="text-[#94A3B8] text-[10px] font-medium tracking-tight uppercase">
© 2026 froydPay Inc. Tüm hakları saklıdır. © 2026 P2CGateway Inc. Tüm hakları saklıdır.
</p> </p>
</footer> </footer>
</div> </div>

View File

@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "P2CGateway | Güvenli Ödeme Altyapısı",
description: "Generated by create next app", description: "P2CGateway ile farklı ödeme sistemlerini tek elden yönetin. Stripe, Cryptomus ve daha fazlası.",
}; };
export default function RootLayout({ export default function RootLayout({

View File

@@ -55,7 +55,7 @@ export default function LoginPage() {
required required
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="admin@froyd.io" placeholder="admin@p2cgateway.com"
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" 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>

View File

@@ -0,0 +1,159 @@
import React from 'react';
import { supabaseAdmin } from '@/lib/supabase-admin';
import {
Terminal,
Copy,
Check,
Globe,
Webhook,
Zap,
ShieldCheck,
Code2
} from 'lucide-react';
import Link from 'next/link';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
async function getMerchant(identifier: string) {
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(identifier);
const query = supabaseAdmin
.from('merchants')
.select('*');
if (isUUID) {
query.eq('id', identifier);
} else {
query.eq('short_id', identifier);
}
const { data, error } = await query.single();
return data;
}
export default async function MerchantIntegrationPage(props: {
params: Promise<{ id: string }>;
}) {
const resolvedParams = await props.params;
const identifier = resolvedParams.id;
const merchant = await getMerchant(identifier);
const cookieStore = await cookies();
if (!merchant) return null;
if (!cookieStore.get(`merchant_auth_${merchant.id}`)) {
redirect(`/merchant/${identifier}/login`);
}
const checkoutUrl = `https://p2cgateway.com/checkout?merchant_id=${merchant.short_id || merchant.id}&amount=100&currency=TRY&ref_id=SİPARİŞ_123&callback_url=https://siteniz.com/basarili`;
return (
<div className="max-w-5xl space-y-12 animate-in fade-in slide-in-from-bottom-4 duration-700 pb-20">
{/* Header */}
<div>
<h1 className="text-3xl font-black text-gray-900 tracking-tight">Teknik Entegrasyon</h1>
<p className="text-xs text-gray-400 font-bold uppercase tracking-widest mt-2 px-1">Ödeme sistemini sitenize nasıl bağlarsınız?</p>
</div>
{/* Quick Start Card */}
<div className="bg-gray-900 rounded-[40px] p-12 text-white relative overflow-hidden shadow-2xl">
<div className="relative z-10 grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
<div className="space-y-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-blue-600 rounded-2xl flex items-center justify-center">
<Zap size={24} />
</div>
<h2 className="text-2xl font-black">Hızlı Ödeme Linki</h2>
</div>
<p className="text-gray-400 text-lg leading-relaxed font-medium">
Entegrasyonun en basit yolu, müşterilerinizi aşağıdaki URL yapısını kullanarak ödeme sayfasına yönlendirmektir.
</p>
</div>
<div className="bg-white/5 p-6 rounded-3xl border border-white/10 space-y-4">
<p className="text-[10px] font-black text-gray-500 uppercase tracking-widest">Sizin Hazır Linkiniz</p>
<div className="bg-black p-4 rounded-xl border border-white/5 font-mono text-[10px] text-blue-400 break-all leading-relaxed">
{checkoutUrl}
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Credentials */}
<div className="bg-white p-10 rounded-[40px] border border-gray-100 shadow-sm space-y-8">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-blue-50 rounded-2xl flex items-center justify-center text-blue-600">
<ShieldCheck size={24} />
</div>
<h3 className="text-xl font-black text-gray-900">Kimlik Bilgileri</h3>
</div>
<div className="space-y-6">
<div className="p-6 bg-gray-50 rounded-3xl border border-gray-100 space-y-3">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest pl-1">Merchant ID (Firma Kimliği)</label>
<div className="flex items-center justify-between gap-3 bg-white p-4 rounded-xl border border-gray-200">
<code className="text-xs font-mono font-bold text-gray-600 truncate">{merchant.id}</code>
<Copy size={14} className="text-gray-300 cursor-pointer" />
</div>
</div>
<div className="p-6 bg-gray-50 rounded-3xl border border-gray-100 space-y-3">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest pl-1">API Secret Key</label>
<div className="flex items-center justify-between gap-3 bg-white p-4 rounded-xl border border-gray-200">
<code className="text-xs font-mono font-bold text-gray-600"></code>
<button className="text-[10px] font-black text-blue-600 uppercase">Göster</button>
</div>
</div>
</div>
</div>
{/* Webhooks */}
<div className="bg-white p-10 rounded-[40px] border border-gray-100 shadow-sm space-y-8">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-purple-50 rounded-2xl flex items-center justify-center text-purple-600">
<Webhook size={24} />
</div>
<h3 className="text-xl font-black text-gray-900">Webhook Yapılandırması</h3>
</div>
<p className="text-sm text-gray-500 font-medium leading-relaxed">
Ödeme başarılı olduğunda sistemimiz belirtilen adrese bir POST isteği gönderir.
</p>
<div className="p-6 bg-gray-50 rounded-3xl border border-gray-100 space-y-4">
<div className="flex items-center justify-between">
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Mevcut Webhook URL</span>
<span className={`text-[10px] font-black px-2 py-0.5 rounded-md ${merchant.webhook_url ? 'bg-emerald-50 text-emerald-600' : 'bg-red-50 text-red-600'}`}>
{merchant.webhook_url ? 'AKTİF' : 'AYARLANMAMIŞ'}
</span>
</div>
<div className="bg-white p-4 rounded-xl border border-gray-200">
<code className="text-xs font-bold text-gray-600 truncate block">
{merchant.webhook_url || 'https://henuz-bir-adres-tanimlanmadi.com'}
</code>
</div>
<p className="text-[10px] text-gray-400 font-bold uppercase text-center mt-2 leading-relaxed">
Webook URL adresinizi değiştirmek için <br /> destek ekibi ile iletişime geçin.
</p>
</div>
</div>
</div>
{/* Resources */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[
{ title: 'API Referansı', icon: Code2, color: 'blue' },
{ title: 'Hazır Kütüphaneler', icon: Terminal, color: 'emerald' },
{ title: 'Teknik Destek', icon: Globe, color: 'purple' },
].map((r) => (
<div key={r.title} className="bg-white p-8 rounded-[32px] border border-gray-100 shadow-sm flex items-center gap-6 hover:border-gray-300 transition-colors cursor-pointer group">
<div className={`w-12 h-12 bg-${r.color}-50 rounded-2xl flex items-center justify-center text-${r.color}-600 group-hover:bg-${r.color}-600 group-hover:text-white transition-all`}>
<r.icon size={22} />
</div>
<span className="text-sm font-black text-gray-900 leading-tight">{r.title}</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,100 @@
import React from 'react';
import Link from 'next/link';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import {
ExternalLink,
Building2,
ShieldCheck
} from 'lucide-react';
import MerchantSidebar from '@/components/merchant/MerchantSidebar';
import { supabaseAdmin } from '@/lib/supabase-admin';
export default async function MerchantLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ id: string }>;
}) {
const resolvedParams = await params;
const identifier = resolvedParams.id;
const cookieStore = await cookies();
// 1. Resolve actual merchant ID
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(identifier);
let resolvedId = identifier;
if (!isUUID) {
const { data: merchant } = await supabaseAdmin
.from('merchants')
.select('id')
.eq('short_id', identifier)
.single();
if (merchant) {
resolvedId = merchant.id;
}
}
// 2. Auth Check
const isAuth = cookieStore.get(`merchant_auth_${resolvedId}`);
const isShortAuth = cookieStore.get(`merchant_auth_${identifier}`);
// If visiting login page, don't check auth or redirect loop
// But layout handles all subpages. Subpage specific logic?
// In Next.js, layout can't easily know the current sub-segment without hooks.
// However, the login page itself has its own layout or is a sibling?
// If the login page is AT /merchant/[id]/login, it is INSIDE this layout.
// We should allow the login page to show.
// Note: To truly exclude the login page from this check in a layout,
// we would usually check the URL, but Server Components layout don't have URL.
// A better approach is to use Middleware for redirs or just check in page.tsx.
// For now, let's keep it simple:
// Since I can't check URL here easily, I will implement the check in the individual pages
// or just assume this layout is only for protected pages.
// Wait, let's make the login page NOT use this layout if possible.
// Actually, I'll just check if auth exists. If not, the pages will handle redirection
// or we can use a "RequireAuth" wrapper.
return (
<div className="flex h-screen bg-[#F8FAFC]">
{/* Sidebar */}
<MerchantSidebar merchantId={identifier} />
{/* 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">
<div className="flex items-center gap-4">
<h2 className="text-xl font-black text-gray-900 tracking-tight uppercase">Dashboard</h2>
<div className="h-6 w-px bg-gray-100 mx-2"></div>
<span className="px-3 py-1 bg-blue-50 text-blue-600 rounded-lg text-[10px] font-black uppercase tracking-widest border border-blue-100">
{identifier}
</span>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 px-4 py-2 bg-emerald-50 rounded-xl border border-emerald-100">
<ShieldCheck size={14} className="text-emerald-600" />
<span className="text-[10px] font-black text-emerald-600 uppercase tracking-widest">Güvenli Oturum</span>
</div>
<div className="h-4 w-px bg-gray-100"></div>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gray-900 flex items-center justify-center text-white font-bold uppercase tracking-tighter shadow-lg shadow-gray-200">
{identifier.slice(0, 2).toUpperCase()}
</div>
</div>
</div>
</header>
{/* Content Area */}
<main className="flex-1 overflow-y-auto p-10">
{children}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,255 @@
import React from 'react';
import { supabaseAdmin } from '@/lib/supabase-admin';
import {
TrendingUp,
TrendingDown,
Users,
Wallet,
CheckCircle2,
Calendar,
ArrowUpRight,
Search
} from 'lucide-react';
import { format } from 'date-fns';
import { tr } from 'date-fns/locale';
import Link from 'next/link';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import TransactionChart from '@/components/admin/TransactionChart';
async function getMerchantData(identifier: string) {
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(identifier);
// Fetch merchant details
const query = supabaseAdmin
.from('merchants')
.select('*');
if (isUUID) {
query.eq('id', identifier);
} else {
query.eq('short_id', identifier);
}
const { data: merchant, error: mError } = await query.single();
if (mError || !merchant) return null;
const id = merchant.id; // Always use UUID for internal lookups
// Fetch merchant transactions
const { data: transactions, error: tError } = await supabaseAdmin
.from('transactions')
.select('*')
.eq('merchant_id', id)
.order('created_at', { ascending: false });
if (tError) 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 totalCount = transactions.length;
const successRate = totalCount > 0 ? (successfulCount / totalCount) * 100 : 0;
// Last 30 days chart data
const chartData = 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 = chartData.find(d => d.date === dateStr);
if (dayMatch) {
dayMatch.amount += Number(t.amount);
}
});
return {
merchant,
transactions,
totalRevenue,
successfulCount,
successRate,
totalCount,
chartData
};
}
export default async function MerchantDashboardPage(props: {
params: Promise<{ id: string }>;
}) {
const resolvedParams = await props.params;
const identifier = resolvedParams.id;
const data = await getMerchantData(identifier);
const cookieStore = await cookies();
if (!data) {
return (
<div className="flex flex-col items-center justify-center h-[60vh] text-center space-y-4">
<div className="w-20 h-20 bg-red-50 rounded-[32px] flex items-center justify-center text-red-500 mb-4">
<Search size={40} />
</div>
<h1 className="text-2xl font-black text-gray-900 uppercase tracking-tight">Firma Bulunamadı</h1>
<p className="text-gray-400 font-bold uppercase tracking-widest text-xs max-w-xs leading-relaxed">
Erişmeye çalıştığınız firma ID'si geçersiz veya yetkiniz yok.
</p>
<Link href="/" className="px-8 py-3 bg-gray-900 text-white rounded-2xl text-xs font-black uppercase tracking-widest">Geri Dön</Link>
</div>
);
}
// Check Authentication
const isAuth = cookieStore.get(`merchant_auth_${data.merchant.id}`) || cookieStore.get(`merchant_auth_${identifier}`);
if (!isAuth) {
redirect(`/merchant/${identifier}/login`);
}
const { merchant, transactions, totalRevenue, successfulCount, successRate, totalCount, chartData } = data;
const recentTransactions = transactions.slice(0, 8);
return (
<div className="space-y-10 animate-in fade-in slide-in-from-bottom-4 duration-700 pb-20">
{/* Merchant Info Header */}
<div className="bg-white p-10 rounded-[40px] border border-gray-100 shadow-sm flex flex-col md:flex-row md:items-center justify-between gap-8">
<div className="flex items-center gap-6">
<div className="w-20 h-20 bg-blue-600 rounded-[28px] flex items-center justify-center text-white font-black text-2xl shadow-xl shadow-blue-100">
{merchant.name.substring(0, 1).toUpperCase()}
</div>
<div>
<h1 className="text-3xl font-black text-gray-900 tracking-tight">{merchant.name}</h1>
<p className="text-[10px] text-gray-400 font-black uppercase tracking-widest mt-1">Hoş Geldiniz, İşlemlerinizi Buradan Takip Edebilirsiniz</p>
</div>
</div>
<div className="flex gap-4">
<div className="px-6 py-4 bg-gray-50 rounded-2xl border border-gray-100">
<p className="text-[9px] text-gray-400 font-black uppercase tracking-[0.2em] mb-1 text-center">Durum</p>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
<span className="text-xs font-black text-gray-900 uppercase">Aktif</span>
</div>
</div>
</div>
</div>
{/* Stats Cards */}
<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 space-y-6 group hover:border-blue-500 transition-colors">
<div className="flex justify-between items-start">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Toplam Ciro</p>
<div className="p-3 bg-blue-50 rounded-xl text-blue-600 group-hover:bg-blue-600 group-hover:text-white transition-colors">
<Wallet size={20} />
</div>
</div>
<div>
<h3 className="text-4xl font-black text-gray-900">{totalRevenue.toLocaleString('tr-TR', { minimumFractionDigits: 2 })} <span className="text-lg">₺</span></h3>
<p className="text-[10px] text-emerald-500 font-black uppercase tracking-tighter mt-3 flex items-center gap-1">
<TrendingUp size={14} /> Başarılı İşlemlerden Gelen
</p>
</div>
</div>
<div className="bg-white p-10 rounded-[40px] border border-gray-100 shadow-sm space-y-6">
<div className="flex justify-between items-start">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">İşlem Sayısı</p>
<div className="p-3 bg-emerald-50 rounded-xl text-emerald-600">
<CheckCircle2 size={20} />
</div>
</div>
<div>
<h3 className="text-4xl font-black text-gray-900">{successfulCount} <span className="text-lg text-gray-300">/ {totalCount}</span></h3>
<p className="text-[10px] text-gray-400 font-black uppercase tracking-tighter mt-3 flex items-center gap-1">
Ödeme Girişi Denemesi
</p>
</div>
</div>
<div className="bg-white p-10 rounded-[40px] border border-gray-100 shadow-sm space-y-6">
<div className="flex justify-between items-start">
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Başarı Oranı</p>
<div className="p-3 bg-orange-50 rounded-xl text-orange-600">
<ArrowUpRight size={20} />
</div>
</div>
<div>
<h3 className="text-4xl font-black text-gray-900">%{successRate.toFixed(1)}</h3>
<div className="w-full bg-gray-50 h-2 rounded-full mt-4 overflow-hidden">
<div className="bg-orange-500 h-full rounded-full transition-all" style={{ width: `${successRate}%` }}></div>
</div>
</div>
</div>
</div>
{/* Chart */}
<div className="bg-white p-10 rounded-[40px] border border-gray-100 shadow-sm">
<div className="mb-10">
<h3 className="text-xl font-black text-gray-900 uppercase tracking-tight">Günlük Gelir Grafiği</h3>
<p className="text-[10px] text-gray-400 font-black uppercase tracking-widest mt-1">Son 30 günlük işlem hacmi</p>
</div>
<TransactionChart data={chartData} />
</div>
{/* Recent Transactions Table */}
<div className="bg-white rounded-[40px] border border-gray-100 shadow-sm overflow-hidden overflow-x-auto">
<div className="p-8 border-b border-gray-50 flex justify-between items-center px-10">
<h2 className="text-lg font-black text-gray-900 uppercase tracking-tight">Son İşlemler</h2>
<Link href={`/merchant/${identifier}/transactions`} className="text-xs font-black text-blue-600 uppercase tracking-widest hover:underline">
Bütün İşlemleri Gör
</Link>
</div>
<table className="w-full text-left">
<thead>
<tr className="bg-gray-50/50 text-gray-400 text-[10px] font-black uppercase tracking-[0.2em] border-b border-gray-50">
<th className="px-10 py-6">İşlem No</th>
<th className="px-10 py-6">Referans / Müşteri</th>
<th className="px-10 py-6">Tarih</th>
<th className="px-10 py-6 text-right">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">
<span className="text-xs font-black text-gray-900 font-mono">#{t.stripe_pi_id?.slice(-8).toUpperCase() || 'EXT-' + t.id.slice(0, 4)}</span>
</td>
<td className="px-10 py-8">
<div className="flex flex-col">
<span className="text-xs font-black text-gray-900 uppercase">{t.customer_name || t.source_ref_id || 'SİSTEM'}</span>
<span className="text-[10px] text-gray-400 font-bold uppercase mt-1">{t.customer_phone || 'İletişim Yok'}</span>
</div>
</td>
<td className="px-10 py-8 text-xs font-bold text-gray-400 uppercase">
{format(new Date(t.created_at), 'dd MMM yyyy, HH:mm', { locale: tr })}
</td>
<td className="px-10 py-8 text-right font-black text-gray-900 text-sm">
{Number(t.amount).toLocaleString('tr-TR', { minimumFractionDigits: 2 })} ₺
</td>
<td className="px-10 py-8 text-center">
<span className={`inline-flex px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest ${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>
</td>
</tr>
))}
</tbody>
</table>
{recentTransactions.length === 0 && (
<div className="p-20 text-center space-y-4">
<Wallet className="w-12 h-12 text-gray-200 mx-auto" />
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Henüz bir işlem bulunmuyor</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,150 @@
import React from 'react';
import { supabaseAdmin } from '@/lib/supabase-admin';
import { format } from 'date-fns';
import { tr } from 'date-fns/locale';
import {
CreditCard,
Search,
Filter,
Download,
Calendar
} from 'lucide-react';
import Link from 'next/link';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
async function getMerchantTransactions(identifier: string) {
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(identifier);
const query = supabaseAdmin
.from('merchants')
.select('id')
if (isUUID) {
query.eq('id', identifier);
} else {
query.eq('short_id', identifier);
}
const { data: merchant } = await query.single();
if (!merchant) return null;
const { data, error } = await supabaseAdmin
.from('transactions')
.select('*')
.eq('merchant_id', merchant.id)
.order('created_at', { ascending: false });
if (error) return null;
return data;
}
export default async function MerchantTransactionsPage(props: {
params: Promise<{ id: string }>;
}) {
const resolvedParams = await props.params;
const identifier = resolvedParams.id;
const transactions = await getMerchantTransactions(identifier);
const cookieStore = await cookies();
if (!transactions) return <div className="p-10 text-gray-400 font-black uppercase text-xs animate-pulse">İşlemler yükleniyor...</div>;
// Resolve merchant UUID briefly for auth check
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(identifier);
let resolvedId = identifier;
if (!isUUID) {
const { data: merchant } = await supabaseAdmin.from('merchants').select('id').eq('short_id', identifier).single();
if (merchant) resolvedId = merchant.id;
}
if (!cookieStore.get(`merchant_auth_${resolvedId}`)) {
redirect(`/merchant/${identifier}/login`);
}
return (
<div className="space-y-10 animate-in fade-in slide-in-from-bottom-4 duration-700 pb-20">
{/* 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">
<div className="w-12 h-12 bg-blue-50 rounded-2xl flex items-center justify-center text-blue-600">
<CreditCard size={24} />
</div>
<div>
<h2 className="text-xl font-black text-gray-900">İşlem Listesi</h2>
<p className="text-xs text-gray-400 font-bold uppercase tracking-widest mt-0.5">Tüm ödeme hareketleri</p>
</div>
</div>
<div className="flex gap-3">
<button className="flex items-center gap-2 px-6 py-3 bg-gray-50 text-gray-600 rounded-xl text-xs font-bold hover:bg-gray-100 transition">
<Download size={16} />
Dışa Aktar (.CSV)
</button>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-[40px] border border-gray-100 shadow-sm overflow-hidden overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="bg-gray-50/50 text-gray-400 text-[10px] font-black uppercase tracking-[0.2em] border-b border-gray-50">
<th className="px-10 py-6">İşlem No</th>
<th className="px-10 py-6">Müşteri / Referans</th>
<th className="px-10 py-6">Tarih</th>
<th className="px-10 py-6 text-right">Tutar</th>
<th className="px-10 py-6 text-center">Durum</th>
<th className="px-10 py-6 text-right">Gateway</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">
<div className="flex flex-col">
<span className="text-xs font-black text-gray-900 font-mono">#{t.stripe_pi_id?.slice(-8).toUpperCase() || 'EXT-' + t.id.slice(0, 4)}</span>
<span className="text-[9px] text-gray-300 font-bold uppercase mt-1">{t.id.slice(0, 8)}</span>
</div>
</td>
<td className="px-10 py-8">
<div className="flex flex-col">
<span className="text-xs font-black text-gray-900 uppercase">{t.customer_name || t.source_ref_id || 'SİSTEM'}</span>
<span className="text-[10px] text-gray-400 font-bold uppercase mt-1">{t.customer_phone || 'İletişim Yok'}</span>
</div>
</td>
<td className="px-10 py-8 text-xs font-bold text-gray-400 uppercase">
<div className="flex items-center gap-2">
<Calendar size={12} />
{format(new Date(t.created_at), 'dd MMM yyyy, HH:mm', { locale: tr })}
</div>
</td>
<td className="px-10 py-8 text-right font-black text-gray-900 text-sm">
{Number(t.amount).toLocaleString('tr-TR', { minimumFractionDigits: 2 })}
</td>
<td className="px-10 py-8 text-center">
<span className={`inline-flex px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest ${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>
</td>
<td className="px-10 py-8 text-right">
<span className="text-[9px] font-black text-blue-600 bg-blue-50 px-2 py-1 rounded-md uppercase tracking-widest">
{t.provider || 'STRIPE'}
</span>
</td>
</tr>
))}
</tbody>
</table>
{transactions.length === 0 && (
<div className="p-20 text-center space-y-4">
<div className="w-16 h-16 bg-gray-50 rounded-2xl flex items-center justify-center text-gray-200 mx-auto">
<CreditCard size={32} />
</div>
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Henüz bir işlem kaydedilmemiş</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
'use client';
import React, { useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { ShieldCheck, ArrowRight, Lock, Building2 } from 'lucide-react';
export default function MerchantLoginPage() {
const [apiKey, setApiKey] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const router = useRouter();
const params = useParams();
const id = params.id as string;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
try {
// We use an API route to verify the key and set a cookie
const response = await fetch(`/api/merchants/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: id, apiKey })
});
const data = await response.json();
if (response.ok) {
router.push(`/merchant/${id}`);
router.refresh();
} else {
setError(data.error || 'Geçersiz anahtar.');
}
} catch (err) {
setError('Bir hata oluştu. Lütfen tekrar deneyin.');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-[#F8FAFC] flex flex-col items-center justify-center p-6">
<div className="w-full max-w-md space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
<div className="text-center space-y-4">
<div className="w-20 h-20 bg-blue-600 rounded-[32px] flex items-center justify-center text-white mx-auto shadow-2xl shadow-blue-100 mb-6">
<Building2 size={32} />
</div>
<h1 className="text-3xl font-black text-gray-900 tracking-tight uppercase">Firma Girişi</h1>
<p className="text-gray-400 font-bold uppercase tracking-widest text-[10px]">P2CGateway Güvenli Erişim Paneli</p>
</div>
<div className="bg-white p-10 rounded-[40px] border border-gray-100 shadow-xl shadow-gray-200/50 space-y-8">
<div className="space-y-2">
<p className="text-sm font-black text-gray-900 leading-tight">Yönetim anahtarınızı girin</p>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Size özel tanımlanan API Secret Key ile giriş yapın.</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-3">
<div className="relative">
<Lock className="absolute left-5 top-1/2 -translate-y-1/2 text-gray-300" size={18} />
<input
type="password"
required
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="••••••••••••••••"
className="w-full pl-14 pr-6 py-4 bg-gray-50 border-2 border-transparent focus:border-blue-500 focus:bg-white rounded-[24px] outline-none transition-all font-mono font-bold text-gray-900"
/>
</div>
{error && <p className="text-[10px] text-red-500 font-black uppercase tracking-widest pl-2">{error}</p>}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-5 bg-gray-900 text-white rounded-2xl font-black text-xs uppercase tracking-[0.2em] hover:bg-black transition shadow-xl disabled:opacity-50 flex items-center justify-center gap-2"
>
{isLoading ? 'Doğrulanıyor...' : 'Paneli Aç'}
{!isLoading && <ArrowRight size={16} />}
</button>
</form>
</div>
<div className="text-center pt-4">
<button
onClick={() => router.push('/')}
className="text-[10px] font-black text-gray-400 uppercase tracking-widest hover:text-gray-900 transition"
>
Ana Sayfaya Dön
</button>
</div>
</div>
<div className="mt-20 flex items-center gap-3 px-6 py-2 bg-blue-50 rounded-full border border-blue-100">
<ShieldCheck size={14} className="text-blue-600" />
<span className="text-[9px] font-black text-blue-600 uppercase tracking-widest">SSL 256-bit Uçtan Uca Şifreleme</span>
</div>
</div>
);
}

43
app/not-found.tsx Normal file
View File

@@ -0,0 +1,43 @@
import Link from 'next/link';
import { ArrowLeft, Search } from 'lucide-react';
export default function NotFound() {
return (
<div className="min-h-screen bg-[#F8FAFC] flex flex-col items-center justify-center p-6 text-center">
<div className="w-24 h-24 bg-blue-100 rounded-[32px] flex items-center justify-center text-blue-600 mb-8 animate-bounce">
<Search size={40} />
</div>
<h1 className="text-9xl font-black text-gray-200 leading-none">404</h1>
<div className="-mt-12 bg-white px-8 py-2 rounded-2xl shadow-sm border border-gray-100">
<h2 className="text-2xl font-black text-gray-900">Sayfa Bulunamadı</h2>
</div>
<p className="text-gray-400 font-bold uppercase tracking-widest text-[10px] mt-8 max-w-xs leading-relaxed">
Aradığınız sayfa taşınmış veya silinmiş olabilir. Kaybolmuş hissetmeyin, sizi ana sayfaya götürelim.
</p>
<div className="flex flex-col sm:flex-row gap-4 mt-12 w-full max-w-sm">
<Link
href="/"
className="flex-1 px-8 py-4 bg-gray-900 text-white rounded-2xl font-black text-xs uppercase tracking-widest hover:bg-black transition shadow-xl"
>
Ana Sayfaya Dön
</Link>
<Link
href="/admin"
className="flex-1 px-8 py-4 bg-white text-gray-900 border border-gray-100 rounded-2xl font-black text-xs uppercase tracking-widest hover:bg-gray-50 transition"
>
Panele Git
</Link>
</div>
<div className="mt-20">
<div className="inline-flex items-center gap-2 px-4 py-2 bg-blue-50 rounded-full text-blue-600 text-[10px] font-black uppercase tracking-widest border border-blue-100">
<div className="w-1.5 h-1.5 bg-blue-600 rounded-full animate-pulse"></div>
P2CGateway Sistemleri Aktif
</div>
</div>
</div>
);
}

View File

@@ -2,91 +2,190 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { ShieldCheck, CreditCard, LayoutDashboard, Zap } from 'lucide-react'; import { useRouter } from 'next/navigation';
import { ShieldCheck, CreditCard, LayoutDashboard, Zap, Building2, ArrowRight } from 'lucide-react';
export default function Home() { export default function Home() {
const [randomAmount, setRandomAmount] = useState(150); const [randomAmount, setRandomAmount] = useState(150);
const [refId, setRefId] = useState('DEMO-123'); const [refId, setRefId] = useState('DEMO-123');
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [merchantId, setMerchantId] = useState('');
const [placeholderIndex, setPlaceholderIndex] = useState(0);
const router = useRouter();
const placeholders = ['P2C-X7R2B9', 'P2C-A1B2C3', 'MERCHANT-ID'];
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
// Random amount between 50 and 5000
setRandomAmount(Math.floor(Math.random() * 4950) + 50); setRandomAmount(Math.floor(Math.random() * 4950) + 50);
// Random ref id
setRefId(`DEMO-${Math.floor(Math.random() * 900) + 100}`); setRefId(`DEMO-${Math.floor(Math.random() * 900) + 100}`);
const interval = setInterval(() => {
setPlaceholderIndex((prev) => (prev + 1) % placeholders.length);
}, 3000);
return () => clearInterval(interval);
}, []); }, []);
// Return a static version or null during SSR to avoid mismatch const handleMerchantLogin = (e: React.FormEvent) => {
// Or just use the state which will be '150' and 'DEMO-123' on server e.preventDefault();
// and then update on client. The mismatch happens because of Math.random() in JSX. if (merchantId.trim()) {
router.push(`/merchant/${merchantId.trim()}/login`);
}
};
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
{/* Hero Section */} {/* Top Mini Nav */}
<header className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-20 pb-16 text-center"> <nav className="absolute top-0 w-full py-8 px-10 flex justify-between items-center z-10">
<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"> <div className="flex items-center gap-2">
<Zap size={16} /> <div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center text-white shadow-lg shadow-blue-100">
<span>v1.0.0 Yayında</span> <Zap size={18} fill="currentColor" />
</div> </div>
<h1 className="text-5xl md:text-7xl font-black text-gray-900 tracking-tight mb-8"> <span className="font-black text-gray-900 tracking-tight text-lg">P2CGateway</span>
Ödemelerinizi <span className="text-blue-600 underline decoration-blue-200 underline-offset-8">froyd</span> ile Güvenceye Alın </div>
<div className="flex items-center gap-6">
<Link href="/admin" className="text-[10px] font-black text-gray-400 uppercase tracking-widest hover:text-blue-600 transition">Sistem Paneli</Link>
</div>
</nav>
{/* Hero Section */}
<header className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-44 pb-16 text-center relative">
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full bg-blue-50 text-blue-700 text-[10px] font-black uppercase tracking-widest mb-10 border border-blue-100/50">
<Zap size={12} fill="currentColor" />
<span>v1.2.0 Global Deployment</span>
</div>
<h1 className="text-5xl md:text-8xl font-black text-gray-900 tracking-tight mb-8">
Ödemelerinizi <span className="text-blue-600">Tek Bir</span> <br /> Kanaldan Yönetin
</h1> </h1>
<p className="text-xl text-gray-600 max-w-2xl mx-auto mb-10"> <p className="text-xl text-gray-500 max-w-3xl mx-auto mb-14 font-medium leading-relaxed">
Stripe altyapısı ile projelerinize kolayca ödeme geçidi ekleyin. Merkezi yönetim paneli ile tüm işlemlerinizi tek bir yerden takip edin. Sınırları aşan bir ödeme deneyimi. Stripe'ın küresel gücünü, Nuvei'nin esnekliğini ve <br className="hidden md:block" />
kripto dünyasının özgürlüğünü tek bir entegrasyonla işinize katın. <br className="hidden md:block" />
Her işlemde <strong>maksimum dönüşüm</strong>, her saniyede <strong>tam güvenlik</strong>.
</p> </p>
<div className="flex flex-col items-center justify-center gap-10 mb-20">
<div className="flex flex-col sm:flex-row items-center justify-center gap-4"> <div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Link <Link
href={`/checkout?amount=${randomAmount}&currency=TRY&ref_id=${refId}&callback_url=http://localhost:3000`} 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" className="px-10 py-5 bg-blue-600 text-white rounded-2xl font-black text-sm uppercase tracking-widest hover:bg-blue-700 transition shadow-2xl shadow-blue-200 hover:scale-105 active:scale-95"
> >
{mounted ? `Test Ödemesi Başlat (${randomAmount.toLocaleString('tr-TR')})` : 'Ödeme Sayfasını Test Et'} {mounted ? `Canlı Test: ${randomAmount.toLocaleString('tr-TR')} Ödeme Sayfası` : 'Ödeme Sayfasını Test Et'}
</Link> </Link>
<Link </div>
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" <div className="h-px w-20 bg-gray-100"></div>
{/* Merchant Portal Quick Access */}
<div className="w-full max-w-md bg-white p-8 rounded-[40px] border border-gray-100 shadow-xl shadow-gray-200/40 space-y-6">
<div className="flex items-center gap-3 justify-center mb-2">
<Building2 size={20} className="text-blue-600" />
<h3 className="text-xs font-black text-gray-900 uppercase tracking-widest">Firma Yönetim Portalı</h3>
</div>
<form onSubmit={handleMerchantLogin} className="flex gap-2">
<input
type="text"
value={merchantId}
onChange={(e) => setMerchantId(e.target.value)}
placeholder={`Örn: ${placeholders[placeholderIndex]}`}
className="flex-1 px-6 py-4 bg-gray-50 border-2 border-transparent focus:border-blue-500 focus:bg-white rounded-2xl outline-none transition-all font-bold text-sm text-gray-900 placeholder:text-gray-300"
/>
<button
type="submit"
className="px-6 py-4 bg-gray-900 text-white rounded-2xl hover:bg-black transition-all shadow-lg active:scale-95"
> >
Admin Panelini Gör <ArrowRight size={20} />
</Link> </button>
</form>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-tight">Kayıtlı firmanızın paneline erişmek için ID giriniz.</p>
</div>
</div> </div>
</header> </header>
{/* Features */} {/* Supported Gateways */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 border-t border-gray-50"> <section className="bg-[#F8FAFC] py-24">
<div className="grid grid-cols-1 md:grid-cols-3 gap-12"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div className="space-y-4"> <p className="text-[10px] font-black text-blue-600 uppercase tracking-[0.3em] mb-4">Desteklenen Altyapılar</p>
<div className="bg-blue-600 w-12 h-12 rounded-2xl flex items-center justify-center text-white shadow-lg shadow-blue-200"> <h2 className="text-3xl font-black text-gray-900 mb-16">Global Ödeme Çözümleri ile Tam Entegrasyon</h2>
<ShieldCheck size={24} /> <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-8 items-center opacity-60 grayscale hover:grayscale-0 transition-all">
{['Stripe', 'Cryptomus', 'Nuvei', 'PayKings', 'SecurionPay'].map((brand) => (
<div key={brand} className="bg-white p-8 rounded-3xl shadow-sm border border-gray-100 flex items-center justify-center font-black text-xl text-gray-400">
{brand}
</div> </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>
</div> </div>
</section> </section>
{/* Code Snippet Section */}
<section className="py-24 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="bg-gray-900 rounded-[48px] overflow-hidden flex flex-col lg:flex-row shadow-2xl">
<div className="p-12 lg:p-20 flex-1 space-y-8">
<h2 className="text-4xl font-black text-white leading-tight">Tek Satır Kodla Ödeme Almaya Başlayın</h2>
<p className="text-gray-400 text-lg leading-relaxed">
API dokümantasyonumuz sayesinde projelerinize saniyeler içinde ödeme kabiliyeti kazandırın.
Karmaşık backend süreçlerini biz halledelim, siz işinizi büyütün.
</p>
<Link href="/admin/docs" className="inline-flex items-center gap-3 text-blue-400 font-bold hover:gap-5 transition-all">
Dokümantasyonu İncele <Zap size={20} />
</Link>
</div>
<div className="flex-1 bg-black/40 p-8 lg:p-12 font-mono text-sm leading-relaxed overflow-x-auto border-l border-white/5">
<div className="flex gap-2 mb-6">
<div className="w-3 h-3 rounded-full bg-red-500/20"></div>
<div className="w-3 h-3 rounded-full bg-yellow-500/20"></div>
<div className="w-3 h-3 rounded-full bg-green-500/20"></div>
</div>
<pre className="text-blue-300">
{`// Ödeme Linki Oluşturun
const checkoutUrl = "https://p2cgateway.com/checkout";
const params = {
merchant_id: "m_92183",
amount: 250.00,
currency: "TRY",
ref_id: "ORDER_923",
callback_url: "https://siteniz.com/succes"
};
// Müşteriyi yönlendirin
window.location.href = \`\${checkoutUrl}?\${new URLSearchParams(params)}\`;`}
</pre>
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-24 text-center">
<div className="max-w-4xl mx-auto px-4 bg-blue-50/50 p-20 rounded-[64px] border border-blue-100/50">
<h2 className="text-4xl font-black text-gray-900 mb-6 uppercase tracking-tight">İşinizi Bugün Büyütün</h2>
<p className="text-lg text-gray-600 mb-10">P2CGateway ile global pazarlara ılmak artık çok daha kolay.</p>
<Link
href="/admin"
className="px-12 py-5 bg-blue-600 text-white rounded-2xl font-black text-lg hover:bg-blue-700 transition shadow-2xl shadow-blue-200 uppercase tracking-widest"
>
Hemen Ücretsiz Başlayın
</Link>
</div>
</section>
{/* Footer */} {/* Footer */}
<footer className="py-12 border-t border-gray-100 text-center"> <footer className="py-20 border-t border-gray-50 text-center space-y-6">
<p className="text-gray-400 text-sm">© 2026 froyd Payment Platforms. Tüm hakları saklıdır.</p> <div className="flex items-center justify-center gap-2 mb-2">
<div className="w-6 h-6 bg-gray-100 rounded flex items-center justify-center text-gray-400">
<Zap size={14} fill="currentColor" />
</div>
<span className="font-black text-gray-900 tracking-tight">P2CGateway</span>
</div>
<p className="text-gray-400 text-xs font-bold uppercase tracking-widest">© 2026 P2CGateway Platformu. Tüm hakları saklıdır.</p>
<div className="pt-4">
<a
href="https://ayris.tech"
target="_blank"
rel="noopener noreferrer"
className="text-[10px] font-black text-gray-300 uppercase tracking-[0.2em] hover:text-blue-600 transition"
>
Created by <span className="text-gray-400">AyrisTech</span>
</a>
</div>
</footer> </footer>
</div> </div>
); );

View File

@@ -0,0 +1,146 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { X, Building2, Globe, CheckCircle2, Loader2 } from 'lucide-react';
interface AddMerchantModalProps {
isOpen: boolean;
onClose: () => void;
}
export default function AddMerchantModal({ isOpen, onClose }: AddMerchantModalProps) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [name, setName] = useState('');
const [webhookUrl, setWebhookUrl] = useState('');
const [success, setSuccess] = useState(false);
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
const response = await fetch('/api/merchants', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, webhook_url: webhookUrl }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Firma eklenemedi.');
}
setSuccess(true);
setTimeout(() => {
onClose();
setSuccess(false);
setName('');
setWebhookUrl('');
router.refresh();
}, 2000);
} catch (err: any) {
console.error('Error adding merchant:', err);
alert(err.message || 'Firma eklenirken bir hata oluştu.');
} finally {
setIsLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-gray-900/40 backdrop-blur-sm transition-opacity"
onClick={onClose}
></div>
{/* Modal Content */}
<div className="relative bg-white w-full max-w-lg rounded-[40px] shadow-2xl border border-gray-100 overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="p-8">
<div className="flex justify-between items-center mb-8">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-50 rounded-xl flex items-center justify-center text-blue-600">
<Building2 size={20} />
</div>
<h2 className="text-xl font-black text-gray-900">Yeni Firma Ekle</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-50 rounded-full transition-colors text-gray-400"
>
<X size={20} />
</button>
</div>
{success ? (
<div className="py-12 flex flex-col items-center text-center space-y-4">
<div className="w-20 h-20 bg-emerald-50 rounded-full flex items-center justify-center text-emerald-500 animate-bounce">
<CheckCircle2 size={40} />
</div>
<div>
<h3 className="text-xl font-black text-gray-900">Firma Eklendi!</h3>
<p className="text-gray-500 text-sm mt-1">Firma başarıyla kaydedildi, yönlendiriliyorsunuz...</p>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-[0.2em] ml-1">Firma Adı</label>
<div className="relative">
<input
required
type="text"
placeholder="Örn: X Firması Limited"
value={name}
onChange={(e) => setName(e.target.value)}
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 placeholder:text-gray-300"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-[0.2em] ml-1">Geri Dönüş (Webhook) URL</label>
<div className="relative">
<Globe className="absolute left-6 top-1/2 -translate-y-1/2 text-gray-300" size={18} />
<input
type="url"
placeholder="https://firma.com/api/callback"
value={webhookUrl}
onChange={(e) => setWebhookUrl(e.target.value)}
className="w-full pl-14 pr-6 py-4 bg-gray-50 border-none rounded-2xl text-sm font-bold focus:ring-2 focus:ring-blue-500 outline-none placeholder:text-gray-300"
/>
</div>
<p className="text-[10px] text-gray-400 font-medium ml-1">Ödeme başarılı olduğunda sistemin bu adrese veri göndermesini istiyorsanız girin.</p>
</div>
<div className="pt-4 flex gap-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-6 py-4 bg-gray-50 text-gray-500 rounded-2xl text-sm font-bold hover:bg-gray-100 transition"
>
İptal
</button>
<button
type="submit"
disabled={isLoading}
className="flex-1 px-6 py-4 bg-blue-600 text-white rounded-2xl text-sm font-bold hover:bg-blue-700 transition shadow-lg shadow-blue-100 disabled:opacity-50 flex items-center justify-center gap-2"
>
{isLoading ? (
<Loader2 className="animate-spin" size={18} />
) : (
'Firmayı Oluştur'
)}
</button>
</div>
</form>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
'use client';
import React from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell
} from 'recharts';
interface AnalyticsBarChartProps {
data: {
label: string;
amount: number;
}[];
}
export default function AnalyticsBarChart({ data }: AnalyticsBarChartProps) {
return (
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={data}
margin={{
top: 20,
right: 0,
left: 0,
bottom: 0,
}}
>
<CartesianGrid
vertical={false}
strokeDasharray="3 3"
stroke="#F1F5F9"
/>
<XAxis
dataKey="label"
axisLine={false}
tickLine={false}
tick={{ fill: '#94A3B8', fontSize: 10, fontWeight: 700 }}
dy={10}
/>
<YAxis
hide
/>
<Tooltip
content={<CustomTooltip />}
cursor={{ fill: '#F8FAFC' }}
/>
<Bar
dataKey="amount"
radius={[6, 6, 0, 0]}
animationDuration={1500}
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill="#2563EB" />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}
function CustomTooltip({ active, payload, label }: any) {
if (active && payload && payload.length) {
return (
<div className="bg-gray-900 px-4 py-3 rounded-2xl shadow-2xl border border-white/10 backdrop-blur-md">
<p className="text-[10px] font-black text-blue-400 uppercase tracking-widest mb-1">{label}</p>
<p className="text-sm font-black text-white">
{payload[0].value.toLocaleString('tr-TR', { minimumFractionDigits: 2 })}
</p>
</div>
);
}
return null;
}

View File

@@ -0,0 +1,41 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Search } from 'lucide-react';
export default function CustomerSearch() {
const router = useRouter();
const searchParams = useSearchParams();
const [searchTerm, setSearchTerm] = useState(searchParams.get('q') || '');
useEffect(() => {
const currentQ = searchParams.get('q') || '';
if (searchTerm === currentQ) return;
const delayDebounceFn = setTimeout(() => {
const params = new URLSearchParams(searchParams.toString());
if (searchTerm) {
params.set('q', searchTerm);
} else {
params.delete('q');
}
router.push(`/admin/customers?${params.toString()}`);
}, 500);
return () => clearTimeout(delayDebounceFn);
}, [searchTerm, searchParams, router]);
return (
<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..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
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>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
export default function QueryRangeSelector() {
const router = useRouter();
const searchParams = useSearchParams();
const currentRange = searchParams.get('range') || '30';
const handleRangeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newRange = e.target.value;
const params = new URLSearchParams(searchParams.toString());
params.set('range', newRange);
router.push(`/admin?${params.toString()}`);
};
return (
<select
value={currentRange}
onChange={handleRangeChange}
className="bg-gray-50 border-none rounded-xl text-[10px] font-black uppercase tracking-widest px-4 py-2 outline-none cursor-pointer hover:bg-gray-100 transition-colors"
>
<option value="30">Son 30 Gün</option>
<option value="7">Son 7 Gün</option>
<option value="14">Son 14 Gün</option>
</select>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import React from 'react';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer
} from 'recharts';
interface TransactionChartProps {
data: {
displayDate: string;
amount: number;
}[];
}
export default function TransactionChart({ data }: TransactionChartProps) {
return (
<div className="h-72 w-full mt-6">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={data}
margin={{
top: 10,
right: 10,
left: 0,
bottom: 0,
}}
>
<defs>
<linearGradient id="colorAmount" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#2563EB" stopOpacity={0.1} />
<stop offset="95%" stopColor="#2563EB" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
vertical={false}
strokeDasharray="3 3"
stroke="#F1F5F9"
/>
<XAxis
dataKey="displayDate"
axisLine={false}
tickLine={false}
tick={{ fill: '#94A3B8', fontSize: 10, fontWeight: 700 }}
dy={10}
interval={3} // Show fewer labels for clarity
/>
<YAxis
hide
/>
<Tooltip
content={<CustomTooltip />}
cursor={{ stroke: '#2563EB', strokeWidth: 1, strokeDasharray: '4 4' }}
/>
<Area
type="monotone"
dataKey="amount"
stroke="#2563EB"
strokeWidth={3}
fillOpacity={1}
fill="url(#colorAmount)"
animationDuration={1500}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
function CustomTooltip({ active, payload, label }: any) {
if (active && payload && payload.length) {
return (
<div className="bg-gray-900 px-4 py-3 rounded-2xl shadow-2xl border border-white/10 backdrop-blur-md">
<p className="text-[10px] font-black text-blue-400 uppercase tracking-widest mb-1">{label}</p>
<p className="text-sm font-black text-white">
{payload[0].value.toLocaleString('tr-TR', { minimumFractionDigits: 2 })}
</p>
</div>
);
}
return null;
}

View File

@@ -0,0 +1,41 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Search } from 'lucide-react';
export default function TransactionSearch() {
const router = useRouter();
const searchParams = useSearchParams();
const [searchTerm, setSearchTerm] = useState(searchParams.get('q') || '');
useEffect(() => {
const currentQ = searchParams.get('q') || '';
if (searchTerm === currentQ) return;
const delayDebounceFn = setTimeout(() => {
const params = new URLSearchParams(searchParams.toString());
if (searchTerm) {
params.set('q', searchTerm);
} else {
params.delete('q');
}
router.push(`/admin/transactions?${params.toString()}`);
}, 500);
return () => clearTimeout(delayDebounceFn);
}, [searchTerm, searchParams, router]);
return (
<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..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
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>
);
}

View File

@@ -0,0 +1,40 @@
'use client';
import React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Filter } from 'lucide-react';
export default function TransactionStatusFilter() {
const router = useRouter();
const searchParams = useSearchParams();
const currentStatus = searchParams.get('status') || '';
const handleStatusChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
if (value === currentStatus) return;
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set('status', value);
} else {
params.delete('status');
}
router.push(`/admin/transactions?${params.toString()}`);
};
return (
<div className="flex items-center gap-2 px-4 py-3 bg-white border border-gray-100 rounded-2xl">
<Filter size={18} className="text-gray-400" />
<select
value={currentStatus}
onChange={handleStatusChange}
className="bg-transparent border-none text-sm font-bold text-gray-600 outline-none cursor-pointer"
>
<option value="">Tüm Durumlar</option>
<option value="succeeded">Başarılı</option>
<option value="pending">Bekliyor</option>
<option value="failed">Hatalı</option>
</select>
</div>
);
}

View File

@@ -0,0 +1,91 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import {
LayoutDashboard,
CreditCard,
ExternalLink,
Terminal,
Building2,
ShieldCheck,
LogOut
} from 'lucide-react';
export default function MerchantSidebar({ merchantId }: { merchantId: string }) {
const pathname = usePathname();
const router = useRouter();
const handleLogout = async () => {
try {
await fetch('/api/merchants/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: merchantId })
});
router.push('/');
router.refresh();
} catch (err) {
console.error('Logout failed');
}
};
const navItems = [
{ label: 'Panel', icon: LayoutDashboard, href: `/merchant/${merchantId}` },
{ label: 'İşlemler', icon: CreditCard, href: `/merchant/${merchantId}/transactions` },
{ label: 'Entegrasyon', icon: Terminal, href: `/merchant/${merchantId}/integration` },
];
return (
<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-blue-600 rounded-xl flex items-center justify-center text-white shadow-lg shadow-blue-100">
<Building2 size={24} />
</div>
<div>
<h1 className="font-black text-gray-900 leading-tight text-lg">P2C<span className="text-blue-600">Merchant</span></h1>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Firma Yönetim Paneli</p>
</div>
</div>
<nav className="flex-1 px-4 mt-4 space-y-2">
<p className="px-6 text-[9px] font-black text-gray-300 uppercase tracking-[0.2em] mb-4">Menü</p>
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.label}
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-600 text-white shadow-lg shadow-blue-100'
: 'text-gray-400 hover:text-gray-900 hover:bg-gray-50'
}`}
>
<item.icon size={20} className={isActive ? 'text-white' : 'text-gray-300 group-hover:text-gray-500'} />
{item.label}
</Link>
);
})}
</nav>
<div className="p-4 space-y-2">
<div className="p-6 bg-blue-50/50 rounded-3xl space-y-4 border border-blue-100/50 mb-4">
<div className="flex items-center gap-2 text-blue-600">
<ShieldCheck size={14} />
<span className="text-[9px] font-black uppercase tracking-widest">Güvenli Oturum</span>
</div>
<p className="text-[10px] text-gray-500 font-medium leading-relaxed">Verileriniz 256-bit şifreleme ile korunmaktadır.</p>
</div>
<button
onClick={handleLogout}
className="w-full flex items-center gap-4 px-6 py-4 rounded-2xl text-sm font-bold text-red-400 hover:bg-red-50 transition-colors group"
>
<LogOut size={20} className="text-red-200 group-hover:text-red-400" />
Çıkış Yap
</button>
</div>
</aside>
);
}

View File

@@ -5,8 +5,14 @@ CREATE TABLE admin_users (
created_at TIMESTAMPTZ DEFAULT NOW() created_at TIMESTAMPTZ DEFAULT NOW()
); );
-- Register initial admin (User should replace this or add via dashboard) -- Merchants (Firms) table
-- INSERT INTO admin_users (email) VALUES ('your-email@example.com'); CREATE TABLE merchants (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
api_key TEXT UNIQUE DEFAULT encode(gen_random_bytes(32), 'hex'),
webhook_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Transactions table -- Transactions table
CREATE TABLE transactions ( CREATE TABLE transactions (
@@ -20,18 +26,27 @@ CREATE TABLE transactions (
customer_name TEXT, customer_name TEXT,
customer_phone TEXT, customer_phone TEXT,
callback_url TEXT, callback_url TEXT,
merchant_id UUID REFERENCES merchants(id),
metadata JSONB DEFAULT '{}'::jsonb metadata JSONB DEFAULT '{}'::jsonb
); );
-- Enable RLS -- Enable RLS
ALTER TABLE transactions ENABLE ROW LEVEL SECURITY; ALTER TABLE transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE merchants ENABLE ROW LEVEL SECURITY;
-- Create policy for admins to read all -- Create policy for admins to read all
CREATE POLICY "Admins can read all transactions" ON transactions CREATE POLICY "Admins can read all transactions" ON transactions
FOR SELECT FOR SELECT
USING (auth.jwt() ->> 'email' IN (SELECT email FROM admin_users)); USING (auth.jwt() ->> 'email' IN (SELECT email FROM admin_users));
CREATE POLICY "Admins can manage merchants" ON merchants
USING (auth.jwt() ->> 'email' IN (SELECT email FROM admin_users));
-- Create policy for service role to manage all -- Create policy for service role to manage all
CREATE POLICY "Service role can manage all" ON transactions CREATE POLICY "Service role can manage all transactions" ON transactions
USING (true)
WITH CHECK (true);
CREATE POLICY "Service role can manage all merchants" ON merchants
USING (true) USING (true)
WITH CHECK (true); WITH CHECK (true);

View File

@@ -0,0 +1,17 @@
-- Create merchants table
CREATE TABLE merchants (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
api_key TEXT UNIQUE DEFAULT encode(gen_random_bytes(32), 'hex'),
webhook_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Add merchant_id to transactions
ALTER TABLE transactions ADD COLUMN merchant_id UUID REFERENCES merchants(id);
-- Update RLS for transactions to support merchants (future proofing)
-- For now, we'll just keep the admin policy as is, but we'll add more later.
-- Optional: Add index for performance
CREATE INDEX idx_transactions_merchant_id ON transactions(merchant_id);

View File

@@ -0,0 +1,11 @@
-- Add payment provider configuration to merchants table
ALTER TABLE merchants ADD COLUMN payment_provider TEXT NOT NULL DEFAULT 'stripe';
ALTER TABLE merchants ADD COLUMN provider_config JSONB DEFAULT '{}'::jsonb;
-- Add provider info to transactions to track which one was used
ALTER TABLE transactions ADD COLUMN provider TEXT NOT NULL DEFAULT 'stripe';
ALTER TABLE transactions ADD COLUMN provider_tx_id TEXT;
ALTER TABLE transactions ADD COLUMN provider_status TEXT;
-- Update status constraint if needed (ours was already quite flexible, but let's be sure)
-- Currently: CHECK (status IN ('pending', 'succeeded', 'failed'))

View File

@@ -0,0 +1,25 @@
-- Add short_id column to merchants
ALTER TABLE merchants ADD COLUMN IF NOT EXISTS short_id TEXT UNIQUE;
-- Function to generate a random short ID
CREATE OR REPLACE FUNCTION generate_short_id() RETURNS TEXT AS $$
DECLARE
chars TEXT := 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
result TEXT := '';
i INTEGER := 0;
BEGIN
FOR i IN 1..8 LOOP
result := result || substr(chars, floor(random() * length(chars) + 1)::integer, 1);
END LOOP;
RETURN result;
END;
$$ LANGUAGE plpgsql;
-- Update existing merchants with a short_id
UPDATE merchants SET short_id = generate_short_id() WHERE short_id IS NULL;
-- Make short_id required for further inserts
-- ALTER TABLE merchants ALTER COLUMN short_id SET NOT NULL; -- Can do this after update
-- Add owner_id to merchants to link with Supabase Auth users
ALTER TABLE merchants ADD COLUMN IF NOT EXISTS owner_id UUID REFERENCES auth.users(id);

78
lib/payment-providers.ts Normal file
View File

@@ -0,0 +1,78 @@
import { stripe } from './stripe';
export interface PaymentIntentOptions {
amount: number;
currency: string;
merchantId: string;
refId?: string;
customerName?: string;
customerPhone?: string;
callbackUrl?: string;
providerConfig?: any;
}
export interface PaymentIntentResponse {
clientSecret: string;
providerTxId: string;
nextAction?: 'redirect' | 'iframe' | 'none';
redirectUrl?: string;
}
export const PaymentProviderFactory = {
async createIntent(provider: string, options: PaymentIntentOptions): Promise<PaymentIntentResponse> {
switch (provider.toLowerCase()) {
case 'stripe':
return this.handleStripe(options);
case 'cryptomus':
return this.handleCryptomus(options);
case 'nuvei':
case 'paykings':
case 'highriskpay':
case 'paymentcloud':
case 'securionpay':
// For now, these will use mock or a generic handler
return this.handleGeneric(provider, options);
default:
return this.handleStripe(options);
}
},
async handleStripe(options: PaymentIntentOptions): Promise<PaymentIntentResponse> {
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(options.amount * 100),
currency: options.currency.toLowerCase(),
metadata: {
ref_id: options.refId || '',
merchant_id: options.merchantId,
customer_name: options.customerName || '',
customer_phone: options.customerPhone || '',
},
});
return {
clientSecret: paymentIntent.client_secret!,
providerTxId: paymentIntent.id
};
},
async handleCryptomus(options: PaymentIntentOptions): Promise<PaymentIntentResponse> {
// Mock implementation for Cryptomus - real implementation would call their API
// Cryptomus usually returns a payment URL for redirect
const mockTxId = 'crypt_' + Math.random().toString(36).substring(7);
return {
clientSecret: mockTxId,
providerTxId: mockTxId,
nextAction: 'redirect',
redirectUrl: `https://cryptomus.com/pay/${mockTxId}`
};
},
async handleGeneric(provider: string, options: PaymentIntentOptions): Promise<PaymentIntentResponse> {
// Mock generic handler for other providers
const mockTxId = `${provider.slice(0, 3)}_${Math.random().toString(36).substring(7)}`;
return {
clientSecret: `mock_secret_${mockTxId}`,
providerTxId: mockTxId
};
}
};

9
lib/supabase-admin.ts Normal file
View File

@@ -0,0 +1,9 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
// This should ONLY be used in Server Components or API Routes
export const supabaseAdmin = createClient(
supabaseUrl,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);

View File

@@ -4,8 +4,3 @@ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
export const supabase = createClient(supabaseUrl, supabaseAnonKey); export const supabase = createClient(supabaseUrl, supabaseAnonKey);
export const supabaseAdmin = createClient(
supabaseUrl,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);

401
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"next": "16.1.1", "next": "16.1.1",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"recharts": "^3.6.0",
"stripe": "^20.1.2", "stripe": "^20.1.2",
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0"
}, },
@@ -1236,6 +1237,42 @@
"node": ">=12.4.0" "node": ">=12.4.0"
} }
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1243,6 +1280,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@stripe/react-stripe-js": { "node_modules/@stripe/react-stripe-js": {
"version": "5.4.1", "version": "5.4.1",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.4.1.tgz", "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.4.1.tgz",
@@ -1651,6 +1700,69 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1677,7 +1789,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.29.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.29.tgz",
"integrity": "sha512-YrT9ArrGaHForBaCNwFjoqJWmn8G1Pr7+BH/vwyLHciA9qT/wSiuOhxGCT50JA5xLvFBd6PIiGkE3afxcPE1nw==", "integrity": "sha512-YrT9ArrGaHForBaCNwFjoqJWmn8G1Pr7+BH/vwyLHciA9qT/wSiuOhxGCT50JA5xLvFBd6PIiGkE3afxcPE1nw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -1692,7 +1803,7 @@
"version": "19.2.8", "version": "19.2.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
@@ -1709,6 +1820,12 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/ws": { "node_modules/@types/ws": {
"version": "8.18.1", "version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -2795,9 +2912,130 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -2887,6 +3125,12 @@
} }
} }
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3169,6 +3413,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/es-toolkit": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -3618,6 +3872,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -4077,6 +4337,16 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -4119,6 +4389,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-array-buffer": { "node_modules/is-array-buffer": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -5600,7 +5879,78 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT" "license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/recharts": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz",
"integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
}, },
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
@@ -5646,6 +5996,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -6261,6 +6617,12 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -6599,6 +6961,37 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -19,6 +19,7 @@
"next": "16.1.1", "next": "16.1.1",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"recharts": "^3.6.0",
"stripe": "^20.1.2", "stripe": "^20.1.2",
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0"
}, },

View File

@@ -1,7 +1,7 @@
import { type NextRequest } from 'next/server' import { type NextRequest } from 'next/server'
import { updateSession } from '@/utils/supabase/middleware' import { updateSession } from '@/utils/supabase/proxy'
export async function middleware(request: NextRequest) { export async function proxy(request: NextRequest) {
return await updateSession(request) return await updateSession(request)
} }