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:
@@ -9,11 +9,13 @@ import {
|
||||
Smartphone,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
import { supabaseAdmin } from '@/lib/supabase';
|
||||
import { supabaseAdmin } from '@/lib/supabase-admin';
|
||||
import { format, subDays } from 'date-fns';
|
||||
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
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
@@ -25,10 +27,9 @@ async function getAnalyticsData() {
|
||||
const totalRevenue = successfulTransactions.reduce((acc, t) => acc + Number(t.amount), 0);
|
||||
const avgOrderValue = successfulTransactions.length > 0 ? totalRevenue / successfulTransactions.length : 0;
|
||||
|
||||
// Monthly data for chart (grouped by month or last 12 periods)
|
||||
// To keep it simple and meaningful, let's show last 12 days for "Gelir Trendi"
|
||||
const last12Periods = Array.from({ length: 12 }, (_, i) => {
|
||||
const d = subDays(new Date(), 11 - i);
|
||||
// Monthly data for chart (grouped by month or last N periods)
|
||||
const lastPeriods = Array.from({ length: rangeDays }, (_, i) => {
|
||||
const d = subDays(new Date(), (rangeDays - 1) - i);
|
||||
return {
|
||||
date: d.toISOString().split('T')[0],
|
||||
label: format(d, 'd MMM', { locale: tr }),
|
||||
@@ -38,7 +39,7 @@ async function getAnalyticsData() {
|
||||
|
||||
successfulTransactions.forEach(t => {
|
||||
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) {
|
||||
periodMatch.amount += Number(t.amount);
|
||||
}
|
||||
@@ -47,14 +48,18 @@ async function getAnalyticsData() {
|
||||
return {
|
||||
totalRevenue,
|
||||
avgOrderValue,
|
||||
chartData: last12Periods,
|
||||
chartData: lastPeriods,
|
||||
totalCount: transactions.length,
|
||||
successCount: successfulTransactions.length,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AnalyticsPage() {
|
||||
const data = await getAnalyticsData();
|
||||
export default async function AnalyticsPage(props: {
|
||||
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>;
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button className="flex items-center gap-3 px-6 py-4 bg-white border border-gray-100 rounded-2xl text-xs font-black text-gray-600 hover:bg-gray-50 transition uppercase tracking-widest">
|
||||
<Calendar size={18} className="text-gray-300" />
|
||||
Son 30 Gün
|
||||
</button>
|
||||
<QueryRangeSelector />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,25 +115,7 @@ export default async function AnalyticsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-72 flex items-end justify-between gap-4">
|
||||
{data.chartData.map((d, i) => {
|
||||
const h = (d.amount / maxChartAmount) * 90 + 5; // 5% to 95%
|
||||
return (
|
||||
<div key={i} className="flex-1 group relative h-full flex flex-col justify-end">
|
||||
<div
|
||||
className="w-full bg-blue-500 rounded-t-xl transition-all duration-500 group-hover:bg-blue-600 cursor-pointer relative"
|
||||
style={{ height: `${h}%` }}
|
||||
>
|
||||
<div className="absolute -top-12 left-1/2 -translate-x-1/2 bg-gray-900 text-white text-[10px] font-black py-2 px-3 rounded-lg opacity-0 group-hover:opacity-100 transition shadow-xl pointer-events-none whitespace-nowrap z-20">
|
||||
{d.amount.toLocaleString('tr-TR')} ₺
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 text-[9px] font-black text-gray-300 uppercase tracking-tighter text-center">
|
||||
{d.label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<AnalyticsBarChart data={data.chartData} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -172,6 +156,5 @@ export default async function AnalyticsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,12 +8,15 @@ import {
|
||||
MoreHorizontal,
|
||||
ArrowUpRight
|
||||
} 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
|
||||
.from('transactions')
|
||||
.select('*');
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error || !transactions) return null;
|
||||
|
||||
@@ -21,7 +24,10 @@ async function getCustomers() {
|
||||
const customerMap = new Map();
|
||||
|
||||
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)) {
|
||||
customerMap.set(key, {
|
||||
id: t.id,
|
||||
@@ -29,27 +35,45 @@ async function getCustomers() {
|
||||
phone: t.customer_phone || 'Telefon Yok',
|
||||
spent: 0,
|
||||
orders: 0,
|
||||
lastOrder: t.created_at,
|
||||
status: 'New'
|
||||
});
|
||||
}
|
||||
|
||||
const c = customerMap.get(key);
|
||||
c.orders += 1;
|
||||
if (t.status === 'succeeded') {
|
||||
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 => {
|
||||
if (c.orders > 5) c.status = 'High Value';
|
||||
let customers = Array.from(customerMap.values()).map(c => {
|
||||
if (c.orders > 5 && c.spent > 1000) c.status = 'High Value';
|
||||
else if (c.orders > 1) c.status = 'Active';
|
||||
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;
|
||||
}
|
||||
|
||||
export default async function CustomersPage() {
|
||||
const customers = await getCustomers();
|
||||
export default async function CustomersPage(props: {
|
||||
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>;
|
||||
|
||||
@@ -61,21 +85,21 @@ export default async function CustomersPage() {
|
||||
<h1 className="text-3xl font-black text-gray-900 tracking-tight">Müşteriler</h1>
|
||||
<p className="text-sm text-gray-400 font-bold uppercase tracking-widest mt-2">Müşteri portföyünüzü yönetin</p>
|
||||
</div>
|
||||
<button className="flex items-center justify-center gap-3 px-8 py-4 bg-[#2563EB] text-white rounded-2xl font-black shadow-xl shadow-blue-100 hover:bg-blue-700 transition active:scale-95 uppercase text-xs tracking-widest">
|
||||
<Plus size={18} />
|
||||
Yeni Müşteri Ekle
|
||||
</button>
|
||||
<div className="p-3 bg-blue-50/50 rounded-2xl border border-blue-100/50 flex items-center gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></div>
|
||||
<span className="text-[10px] font-black text-blue-600 uppercase tracking-widest">Canlı Veritabanı Bağlantısı</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="bg-white p-10 rounded-[40px] border border-gray-100 shadow-sm flex items-center gap-8">
|
||||
<div className="w-16 h-16 bg-blue-50 rounded-[20px] flex items-center justify-center text-blue-600">
|
||||
<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 group-hover:scale-110 transition-transform">
|
||||
<Users size={32} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-black text-gray-900">{customers.length.toLocaleString('tr-TR')}</p>
|
||||
<p className="text-[10px] text-gray-400 font-black uppercase tracking-widest mt-1">Toplam Müşteri</p>
|
||||
<p className="text-[10px] text-gray-400 font-black uppercase tracking-widest mt-1">Sorgulanan Müşteri</p>
|
||||
</div>
|
||||
</div>
|
||||
<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} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-3xl font-black text-gray-900">Gerçek</p>
|
||||
<p className="text-[10px] text-gray-400 font-black uppercase tracking-widest mt-1">Canlı Veri</p>
|
||||
<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">Bağlılık Oranı</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
@@ -101,17 +125,10 @@ export default async function CustomersPage() {
|
||||
{/* List */}
|
||||
<div className="bg-white rounded-[40px] border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div className="p-8 border-b border-gray-50 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-6 top-1/2 -translate-y-1/2 text-gray-300" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="İsim veya telefon ile ara..."
|
||||
className="w-full pl-16 pr-6 py-5 bg-gray-50 border-none rounded-2xl text-sm font-medium focus:ring-2 focus:ring-blue-500 outline-none placeholder:text-gray-300"
|
||||
/>
|
||||
<CustomerSearch />
|
||||
<div className="bg-gray-50 px-6 py-4 rounded-2xl">
|
||||
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Sıralama: En Son Ödeme</span>
|
||||
</div>
|
||||
<button className="text-blue-600 text-xs font-black uppercase tracking-widest hover:underline decoration-2 underline-offset-4 px-6 py-4">
|
||||
Görünümü Filtrele
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto text-sans tracking-tight">
|
||||
@@ -122,7 +139,7 @@ export default async function CustomersPage() {
|
||||
<th className="px-10 py-8">Segment</th>
|
||||
<th className="px-10 py-8">Sipariş</th>
|
||||
<th className="px-10 py-8">Toplam Harcama</th>
|
||||
<th className="px-10 py-8 text-right">Aksiyonlar</th>
|
||||
<th className="px-10 py-8 text-right">Durum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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">
|
||||
<td className="px-10 py-10">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="w-14 h-14 bg-gray-100 rounded-2xl flex items-center justify-center text-gray-400 font-black text-sm uppercase tracking-tighter">
|
||||
<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()}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
@@ -141,7 +158,7 @@ export default async function CustomersPage() {
|
||||
</td>
|
||||
<td className="px-10 py-10">
|
||||
<span className={`inline-flex items-center px-4 py-1.5 rounded-full text-[10px] font-black uppercase tracking-widest ${customer.status === 'Active' ? 'bg-emerald-50 text-emerald-600' :
|
||||
customer.status === 'High Value' ? 'bg-blue-50 text-blue-600' :
|
||||
customer.status === 'High Value' ? 'bg-blue-600 text-white' :
|
||||
customer.status === 'New' ? 'bg-indigo-50 text-indigo-600' :
|
||||
'bg-gray-50 text-gray-400'
|
||||
}`}>
|
||||
@@ -151,7 +168,7 @@ export default async function CustomersPage() {
|
||||
</span>
|
||||
</td>
|
||||
<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 className="px-10 py-10">
|
||||
<span className="text-sm font-black text-gray-900">
|
||||
@@ -160,12 +177,12 @@ export default async function CustomersPage() {
|
||||
</td>
|
||||
<td className="px-10 py-10 text-right">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button className="p-3 bg-gray-50 text-gray-400 rounded-xl hover:text-blue-600 hover:bg-blue-50 transition">
|
||||
<Phone size={18} />
|
||||
</button>
|
||||
<button className="p-3 bg-gray-50 text-gray-400 rounded-xl hover:text-gray-900 hover:bg-gray-100 transition">
|
||||
<MoreHorizontal size={18} />
|
||||
</button>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-[10px] font-black text-gray-900 uppercase tracking-widest">Son İşlem</span>
|
||||
<span className="text-[10px] text-gray-400 font-bold mt-1 uppercase">
|
||||
{new Date(customer.lastOrder).toLocaleDateString('tr-TR')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -173,6 +190,12 @@ export default async function CustomersPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
);
|
||||
|
||||
184
app/admin/docs/page.tsx
Normal file
184
app/admin/docs/page.tsx
Normal 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¤cy=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>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
Bell,
|
||||
MessageSquare,
|
||||
ChevronDown,
|
||||
Wallet
|
||||
Wallet,
|
||||
Building2,
|
||||
Code2
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { createClient } from '@/utils/supabase/client'; // Assuming a client-side Supabase client utility
|
||||
@@ -36,9 +38,11 @@ export default function AdminLayout({
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Genel Bakış', icon: LayoutDashboard, href: '/admin' },
|
||||
{ label: 'Firmalar', icon: Building2, href: '/admin/merchants' },
|
||||
{ label: 'İşlemler', icon: CreditCard, href: '/admin/transactions' },
|
||||
{ label: 'Müşteriler', icon: Users, href: '/admin/customers' },
|
||||
{ label: 'Analizler', icon: BarChart3, href: '/admin/analytics' },
|
||||
{ label: 'Dokümantasyon', icon: Code2, href: '/admin/docs' },
|
||||
{ label: 'Ayarlar', icon: Settings, href: '/admin/settings' },
|
||||
];
|
||||
|
||||
@@ -51,8 +55,8 @@ export default function AdminLayout({
|
||||
<Wallet size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-black text-gray-900 leading-tight">froyd Admin</h1>
|
||||
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Yönetim Paneli</p>
|
||||
<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">Merkezi Yönetim Paneli</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
222
app/admin/merchants/new/page.tsx
Normal file
222
app/admin/merchants/new/page.tsx
Normal 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 uç 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>
|
||||
);
|
||||
}
|
||||
349
app/admin/merchants/page.tsx
Normal file
349
app/admin/merchants/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { supabaseAdmin } from '@/lib/supabase';
|
||||
import { supabaseAdmin } from '@/lib/supabase-admin';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
import { format } from 'date-fns';
|
||||
import { tr } from 'date-fns/locale';
|
||||
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
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
@@ -34,11 +36,11 @@ async function getStats() {
|
||||
.map(t => t.customer_name || t.customer_phone)
|
||||
).size;
|
||||
|
||||
// Last 30 days chart data
|
||||
const last30Days = Array.from({ length: 30 }, (_, i) => {
|
||||
// Dynamic chart data based on range
|
||||
const chartData = Array.from({ length: rangeDays }, (_, i) => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
d.setDate(d.getDate() - (29 - i));
|
||||
d.setDate(d.getDate() - (rangeDays - 1 - i));
|
||||
return {
|
||||
date: d.toISOString().split('T')[0],
|
||||
displayDate: format(d, 'd MMM', { locale: tr }),
|
||||
@@ -48,7 +50,7 @@ async function getStats() {
|
||||
|
||||
successfulTransactions.forEach(t => {
|
||||
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) {
|
||||
dayMatch.amount += Number(t.amount);
|
||||
}
|
||||
@@ -62,12 +64,16 @@ async function getStats() {
|
||||
successRate,
|
||||
totalCount,
|
||||
uniqueCustomers,
|
||||
chartData: last30Days
|
||||
chartData
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AdminDashboard() {
|
||||
const stats = await getStats();
|
||||
export default async function AdminDashboard(props: {
|
||||
searchParams: Promise<{ range?: string }>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const range = searchParams.range ? parseInt(searchParams.range) : 30;
|
||||
const stats = await getStats(range);
|
||||
|
||||
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>;
|
||||
@@ -96,163 +102,65 @@ export default async function AdminDashboard() {
|
||||
</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="flex justify-between items-start">
|
||||
<p className="text-sm font-bold text-gray-400 uppercase tracking-wider">Toplam Müşteri</p>
|
||||
<div className="p-3 bg-indigo-50 rounded-xl text-indigo-600">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-3xl font-black text-gray-900">{stats.uniqueCustomers.toLocaleString('tr-TR')}</h3>
|
||||
<div className="flex items-center gap-1 mt-2 text-emerald-500 font-bold text-xs uppercase tracking-tighter">
|
||||
<TrendingUp size={14} />
|
||||
<span>{stats.totalCount} <span className="text-gray-400 font-medium lowercase">toplam işlem kaydı</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending Payments */}
|
||||
<div className="bg-white p-8 rounded-3xl border border-gray-100 shadow-sm space-y-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-sm font-bold text-gray-400 uppercase tracking-wider">Bekleyen Ödemeler</p>
|
||||
<div className="p-3 bg-orange-50 rounded-xl text-orange-600">
|
||||
<ClipboardList size={20} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-3xl font-black text-gray-900">{stats.pendingCount}</h3>
|
||||
<div className="flex items-center gap-1 mt-2 text-orange-500 font-bold text-xs uppercase tracking-tighter">
|
||||
<ClipboardList size={14} />
|
||||
<span>İşlem Bekliyor <span className="text-gray-400 font-medium lowercase">onay aşamasında</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success Rate */}
|
||||
<div className="bg-white p-8 rounded-3xl border border-gray-100 shadow-sm space-y-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-sm font-bold text-gray-400 uppercase tracking-wider">Başarı Oranı</p>
|
||||
<p className="text-sm font-bold text-gray-400 uppercase tracking-wider">İş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-3xl font-black text-gray-900">{stats.successRate.toFixed(1)}%</h3>
|
||||
<div className="flex items-center gap-1 mt-2 text-emerald-500 font-bold text-xs uppercase tracking-tighter">
|
||||
<TrendingUp size={14} />
|
||||
<span>Optimized <span className="text-gray-400 font-medium lowercase">ödeme dönüşüm oranı</span></span>
|
||||
<h3 className="text-3xl font-black text-gray-900">{stats.successfulCount}</h3>
|
||||
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest mt-2">Tamamlanan Ödeme</p>
|
||||
</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>
|
||||
|
||||
{/* Middle Section: Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Transaction Volume Line Chart */}
|
||||
<div className="lg:col-span-2 bg-white p-8 rounded-3xl border border-gray-100 shadow-sm">
|
||||
<div className="bg-white p-8 rounded-3xl border border-gray-100 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<div>
|
||||
<h3 className="text-lg font-black text-gray-900 leading-none">İşlem Hacmi</h3>
|
||||
<p className="text-xs text-gray-400 font-bold uppercase tracking-wider mt-2">Son 30 günlük toplam hacim</p>
|
||||
<p className="text-xs text-gray-400 font-bold uppercase tracking-wider mt-2">Son {range} günlük toplam hacim</p>
|
||||
</div>
|
||||
<select className="bg-gray-50 border-none rounded-xl text-[10px] font-black uppercase tracking-widest px-4 py-2 outline-none">
|
||||
<option>Son 30 Gün</option>
|
||||
<option>Son 7 Gün</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="h-64 relative flex items-end justify-between px-4 pb-12">
|
||||
{/* Dynamic SVG Chart */}
|
||||
{(() => {
|
||||
const maxAmount = Math.max(...stats.chartData.map(d => d.amount), 100);
|
||||
const points = stats.chartData.map((d, i) => ({
|
||||
x: (i / 29) * 100, // 0 to 100%
|
||||
y: 100 - (d.amount / maxAmount) * 80 - 10 // 10 to 90% (lower y is higher value)
|
||||
}));
|
||||
|
||||
const dLine = points.reduce((acc, p, i) =>
|
||||
i === 0 ? `M 0 ${p.y}` : `${acc} L ${p.x} ${p.y}`, ''
|
||||
);
|
||||
|
||||
const dArea = `${dLine} L 100 100 L 0 100 Z`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="absolute inset-0 w-full h-full text-blue-500 overflow-visible px-4 pt-10 pb-12"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path
|
||||
d={dLine}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d={dArea}
|
||||
fill="url(#chartGradient)"
|
||||
stroke="none"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="chartGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="currentColor" stopOpacity="0.2" />
|
||||
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 flex justify-between text-[10px] font-bold text-gray-300 uppercase px-8 pb-4 border-t border-gray-50 pt-4">
|
||||
<span>{stats.chartData[0].displayDate}</span>
|
||||
<span>{stats.chartData[10].displayDate}</span>
|
||||
<span>{stats.chartData[20].displayDate}</span>
|
||||
<span>Bugün</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revenue by Source Donut Chart */}
|
||||
<div className="bg-white p-8 rounded-3xl border border-gray-100 shadow-sm flex flex-col">
|
||||
<h3 className="text-lg font-black text-gray-900 leading-none mb-10 uppercase tracking-tight">Kaynak Bazlı Ciro</h3>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center justify-center space-y-8">
|
||||
<div className="relative w-48 h-48">
|
||||
<svg className="w-full h-full -rotate-90">
|
||||
<circle cx="96" cy="96" r="80" stroke="#F1F5F9" strokeWidth="24" fill="none" />
|
||||
<circle cx="96" cy="96" r="80" stroke="#2563EB" strokeWidth="24" fill="none" strokeDasharray="502" strokeDashoffset="200" strokeLinecap="round" />
|
||||
<circle cx="96" cy="96" r="80" stroke="#60A5FA" strokeWidth="24" fill="none" strokeDasharray="502" strokeDashoffset="400" strokeLinecap="round" />
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-sans">
|
||||
<p className="text-2xl font-black text-gray-900 truncate max-w-[120px] text-center">{stats.totalRevenue.toLocaleString('tr-TR', { maximumFractionDigits: 0 })} ₺</p>
|
||||
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">Toplam Ciro</p>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<QueryRangeSelector />
|
||||
<p className="text-[9px] text-gray-400 font-bold uppercase tracking-tighter">
|
||||
{range} günlük veri gösteriliyor
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-4 w-full px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-600"></div>
|
||||
<span className="text-xs font-bold text-gray-900">Kart (60%)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-400"></div>
|
||||
<span className="text-xs font-bold text-gray-900">Havale (20%)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-gray-200"></div>
|
||||
<span className="text-xs font-bold text-gray-900">Cüzdan (15%)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-gray-100"></div>
|
||||
<span className="text-xs font-bold text-gray-400 text-center uppercase tracking-tighter">Diğer (5%)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TransactionChart data={stats.chartData} />
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<td className="px-10 py-8">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-black text-gray-900">#{t.stripe_pi_id.slice(-8).toUpperCase()}</span>
|
||||
<span className="text-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>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -39,11 +39,11 @@ export default function SettingsPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest pl-2">Mağaza Adı</label>
|
||||
<input type="text" defaultValue="froyd Store" className="w-full px-6 py-4 bg-gray-50 border-none rounded-2xl text-sm font-bold focus:ring-2 focus:ring-blue-500 outline-none" />
|
||||
<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 className="space-y-3">
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest pl-2">Destek E-postası</label>
|
||||
<input type="email" defaultValue="support@froyd.io" className="w-full px-6 py-4 bg-gray-50 border-none rounded-2xl text-sm font-bold focus:ring-2 focus:ring-blue-500 outline-none" />
|
||||
<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 className="space-y-3">
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest pl-2">Para Birimi</label>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { supabaseAdmin } from '@/lib/supabase';
|
||||
import { supabaseAdmin } from '@/lib/supabase-admin';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
@@ -9,37 +9,72 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { tr } from 'date-fns/locale';
|
||||
import TransactionSearch from '@/components/admin/TransactionSearch';
|
||||
import TransactionStatusFilter from '@/components/admin/TransactionStatusFilter';
|
||||
|
||||
async function getTransactions() {
|
||||
const { data, error } = await supabaseAdmin
|
||||
async function getTransactions(filters: { merchant_id?: string; q?: string; status?: string }) {
|
||||
let query = supabaseAdmin
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.select('*, merchants(name)')
|
||||
.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;
|
||||
}
|
||||
|
||||
export default async function TransactionsPage() {
|
||||
const transactions = await getTransactions();
|
||||
export default async function TransactionsPage(props: {
|
||||
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 (
|
||||
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
{/* Search and Filters Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 bg-white p-8 rounded-[32px] border border-gray-100 shadow-sm">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="İşlem ID veya referans ile ara..."
|
||||
className="w-full pl-12 pr-6 py-3 bg-gray-50 border-none rounded-2xl text-sm font-medium focus:ring-2 focus:ring-blue-500 outline-none placeholder:text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-6 py-3 bg-white border border-gray-100 rounded-2xl text-sm font-bold text-gray-600 hover:bg-gray-50 transition">
|
||||
<Filter size={18} />
|
||||
Filtreler
|
||||
</button>
|
||||
<TransactionSearch />
|
||||
<TransactionStatusFilter />
|
||||
</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">
|
||||
@@ -54,6 +89,7 @@ export default async function TransactionsPage() {
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="bg-gray-50/30 text-gray-400 text-[10px] font-black uppercase tracking-[0.2em] border-b border-gray-50">
|
||||
<th className="px-10 py-6">Firma</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">Tarih & Saat</th>
|
||||
@@ -63,8 +99,15 @@ export default async function TransactionsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<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">
|
||||
<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">
|
||||
<span className="text-sm font-black text-gray-900 uppercase">#{t.stripe_pi_id?.slice(-12).toUpperCase() || 'MOCK'}</span>
|
||||
</td>
|
||||
|
||||
@@ -1,50 +1,89 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
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) {
|
||||
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(
|
||||
{ error: 'Tutar ve para birimi zorunludur.' },
|
||||
{ error: 'Tutar, para birimi ve firma ID zorunludur.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const useMock = process.env.NEXT_PUBLIC_USE_MOCK_PAYMENTS === 'true';
|
||||
let clientSecret = 'mock_secret_' + Math.random().toString(36).substring(7);
|
||||
let stripeId = 'mock_pi_' + Math.random().toString(36).substring(7);
|
||||
// 1. Fetch Merchant to check provider (Support both UUID and 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(merchant_id);
|
||||
|
||||
if (!useMock) {
|
||||
// 1. Create PaymentIntent in Stripe
|
||||
const paymentIntent = await stripe.paymentIntents.create({
|
||||
amount: Math.round(amount * 100), // Stripe uses subunits (e.g., cents)
|
||||
currency: currency.toLowerCase(),
|
||||
metadata: {
|
||||
ref_id,
|
||||
callback_url,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
},
|
||||
});
|
||||
clientSecret = paymentIntent.client_secret!;
|
||||
stripeId = paymentIntent.id;
|
||||
const query = supabaseAdmin
|
||||
.from('merchants')
|
||||
.select('*');
|
||||
|
||||
if (isUUID) {
|
||||
query.eq('id', merchant_id);
|
||||
} else {
|
||||
query.eq('short_id', merchant_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
|
||||
.from('transactions')
|
||||
.insert({
|
||||
amount,
|
||||
currency,
|
||||
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,
|
||||
customer_name,
|
||||
customer_phone,
|
||||
callback_url,
|
||||
merchant_id: resolvedMerchantId,
|
||||
provider: provider,
|
||||
metadata: {
|
||||
nextAction,
|
||||
redirectUrl
|
||||
}
|
||||
});
|
||||
|
||||
if (dbError) {
|
||||
@@ -53,6 +92,9 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
return NextResponse.json({
|
||||
clientSecret: clientSecret,
|
||||
nextAction,
|
||||
redirectUrl,
|
||||
provider
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('Internal Error:', err);
|
||||
|
||||
91
app/api/merchants/[id]/route.ts
Normal file
91
app/api/merchants/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
60
app/api/merchants/auth/route.ts
Normal file
60
app/api/merchants/auth/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
23
app/api/merchants/logout/route.ts
Normal file
23
app/api/merchants/logout/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
70
app/api/merchants/route.ts
Normal file
70
app/api/merchants/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabaseAdmin } from '@/lib/supabase';
|
||||
import { supabaseAdmin } from '@/lib/supabase-admin';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { supabaseAdmin } from '@/lib/supabase';
|
||||
import { supabaseAdmin } from '@/lib/supabase-admin';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { stripe } from '@/lib/stripe';
|
||||
import { supabaseAdmin } from '@/lib/supabase';
|
||||
import { supabaseAdmin } from '@/lib/supabase-admin';
|
||||
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
|
||||
|
||||
@@ -35,13 +35,45 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
async function handlePaymentSucceeded(paymentIntent: any) {
|
||||
const { error } = await supabaseAdmin
|
||||
// 1. Update status in our DB
|
||||
const { data: transaction, error: updateError } = await supabaseAdmin
|
||||
.from('transactions')
|
||||
.update({ status: 'succeeded' })
|
||||
.eq('stripe_pi_id', paymentIntent.id);
|
||||
.eq('stripe_pi_id', paymentIntent.id)
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating transaction success:', error);
|
||||
if (updateError) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useSearchParams } from 'next/navigation';
|
||||
import CheckoutForm from '@/components/checkout/CheckoutForm';
|
||||
import MockCheckoutForm from '@/components/checkout/MockCheckoutForm';
|
||||
import { Loader2, AlertCircle, ArrowLeft, UserCircle } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link'; // Added Link import
|
||||
|
||||
function CheckoutContent() {
|
||||
@@ -16,8 +15,10 @@ function CheckoutContent() {
|
||||
const currency = searchParams.get('currency') || 'TL';
|
||||
const refId = searchParams.get('ref_id') || 'SEC-99231-TX';
|
||||
const callbackUrl = searchParams.get('callback_url') || '/';
|
||||
const merchantId = searchParams.get('merchant_id') || null;
|
||||
|
||||
const [clientSecret, setClientSecret] = useState<string | null>(null);
|
||||
const [paymentData, setPaymentData] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isMock = process.env.NEXT_PUBLIC_USE_MOCK_PAYMENTS === 'true';
|
||||
@@ -31,7 +32,13 @@ function CheckoutContent() {
|
||||
fetch('/api/create-payment-intent', {
|
||||
method: "POST",
|
||||
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((data) => {
|
||||
@@ -39,6 +46,14 @@ function CheckoutContent() {
|
||||
setError(data.error);
|
||||
} else {
|
||||
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.'));
|
||||
@@ -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-4 h-4 bg-white rotate-45 transform"></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 className="w-8 h-8 bg-orange-100 rounded-full flex items-center justify-center text-orange-500">
|
||||
<UserCircle size={24} />
|
||||
@@ -76,37 +91,47 @@ function CheckoutContent() {
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col lg:flex-row items-stretch max-w-7xl mx-auto w-full px-6 py-12 gap-12">
|
||||
{/* Left Column: Product Info */}
|
||||
<div className="flex-1 flex flex-col justify-center items-center lg:items-end space-y-8 order-2 lg:order-1">
|
||||
<div className="relative group perspective-1000">
|
||||
<div className="w-full max-w-[400px] aspect-square relative rounded-[40px] overflow-hidden shadow-2xl shadow-blue-200/50 transform group-hover:rotate-y-6 transition-transform duration-500 border-8 border-white">
|
||||
<Image
|
||||
<div className="flex-1 flex flex-col lg:flex-row items-stretch w-full overflow-hidden">
|
||||
{/* Left Column: Product Info (Cover Image) */}
|
||||
<div className="flex-1 min-h-[500px] lg:min-h-0 relative group order-2 lg:order-1">
|
||||
<img
|
||||
src="/digital_nft_asset.png"
|
||||
alt="Digital NFT Product"
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-8 pt-20">
|
||||
<span className="text-blue-400 text-[10px] font-bold uppercase tracking-widest mb-2 block">Premium Dijital Varlık</span>
|
||||
<h3 className="text-white text-2xl font-black tracking-tight uppercase">CyberCube #082</h3>
|
||||
<p className="text-gray-300 text-sm mt-2 line-clamp-2">froyd ağına ömür boyu erişim sağlayan özel, yüksek sadakatli 3D üretken dijital koleksiyon parçası.</p>
|
||||
</div>
|
||||
|
||||
{/* Overlay Gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent pointer-events-none" />
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Gloss Effect */}
|
||||
<div className="absolute inset-0 rounded-[40px] bg-gradient-to-tr from-white/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div>
|
||||
<div className="space-y-6">
|
||||
<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 ağına ömür boyu erişim sağlayan özel, yüksek sadakatli 3D üretken dijital koleksiyon parçası.</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center lg:text-right space-y-2 hidden lg:block">
|
||||
<p className="text-gray-400 text-sm font-medium">Satıcı: <span className="text-gray-900 uppercase">Froyd Digital Media INC.</span></p>
|
||||
<p className="text-gray-400 text-sm">Müşteri Desteği: <span className="text-blue-600 hover:underline cursor-pointer">help@froyd.io</span></p>
|
||||
<div className="pt-8 border-t border-white/10 flex flex-col sm:flex-row sm:items-center gap-8">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{/* 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 ? (
|
||||
<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" />
|
||||
@@ -114,9 +139,26 @@ function CheckoutContent() {
|
||||
</div>
|
||||
) : (
|
||||
<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} />
|
||||
) : (
|
||||
) : paymentData?.provider === 'stripe' ? (
|
||||
<Elements stripe={getStripe()} options={{ clientSecret, appearance: { theme: 'stripe' } }}>
|
||||
<CheckoutForm
|
||||
amount={amount}
|
||||
@@ -125,6 +167,12 @@ function CheckoutContent() {
|
||||
piId={clientSecret.split('_secret')[0]}
|
||||
/>
|
||||
</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">
|
||||
@@ -141,7 +189,7 @@ function CheckoutContent() {
|
||||
{/* Footer */}
|
||||
<footer className="py-12 border-t border-gray-100 text-center">
|
||||
<p className="text-[#94A3B8] text-[10px] font-medium tracking-tight uppercase">
|
||||
© 2026 froydPay Inc. Tüm hakları saklıdır.
|
||||
© 2026 P2CGateway Inc. Tüm hakları saklıdır.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "P2CGateway | Güvenli Ödeme Altyapısı",
|
||||
description: "P2CGateway ile farklı ödeme sistemlerini tek elden yönetin. Stripe, Cryptomus ve daha fazlası.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function LoginPage() {
|
||||
required
|
||||
value={email}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
159
app/merchant/[id]/(dashboard)/integration/page.tsx
Normal file
159
app/merchant/[id]/(dashboard)/integration/page.tsx
Normal 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¤cy=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>
|
||||
);
|
||||
}
|
||||
100
app/merchant/[id]/(dashboard)/layout.tsx
Normal file
100
app/merchant/[id]/(dashboard)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
255
app/merchant/[id]/(dashboard)/page.tsx
Normal file
255
app/merchant/[id]/(dashboard)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
app/merchant/[id]/(dashboard)/transactions/page.tsx
Normal file
150
app/merchant/[id]/(dashboard)/transactions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
app/merchant/[id]/login/page.tsx
Normal file
103
app/merchant/[id]/login/page.tsx
Normal 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
43
app/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
203
app/page.tsx
203
app/page.tsx
@@ -2,91 +2,190 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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() {
|
||||
const [randomAmount, setRandomAmount] = useState(150);
|
||||
const [refId, setRefId] = useState('DEMO-123');
|
||||
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(() => {
|
||||
setMounted(true);
|
||||
// Random amount between 50 and 5000
|
||||
setRandomAmount(Math.floor(Math.random() * 4950) + 50);
|
||||
// Random ref id
|
||||
setRefId(`DEMO-${Math.floor(Math.random() * 900) + 100}`);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setPlaceholderIndex((prev) => (prev + 1) % placeholders.length);
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Return a static version or null during SSR to avoid mismatch
|
||||
// Or just use the state which will be '150' and 'DEMO-123' on server
|
||||
// and then update on client. The mismatch happens because of Math.random() in JSX.
|
||||
const handleMerchantLogin = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (merchantId.trim()) {
|
||||
router.push(`/merchant/${merchantId.trim()}/login`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Hero Section */}
|
||||
<header className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-20 pb-16 text-center">
|
||||
<div className="inline-flex items-center space-x-2 px-3 py-1 rounded-full bg-blue-50 text-blue-700 text-sm font-medium mb-6">
|
||||
<Zap size={16} />
|
||||
<span>v1.0.0 Yayında</span>
|
||||
{/* Top Mini Nav */}
|
||||
<nav className="absolute top-0 w-full py-8 px-10 flex justify-between items-center z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center text-white shadow-lg shadow-blue-100">
|
||||
<Zap size={18} fill="currentColor" />
|
||||
</div>
|
||||
<h1 className="text-5xl md:text-7xl font-black text-gray-900 tracking-tight mb-8">
|
||||
Ödemelerinizi <span className="text-blue-600 underline decoration-blue-200 underline-offset-8">froyd</span> ile Güvenceye Alın
|
||||
<span className="font-black text-gray-900 tracking-tight text-lg">P2CGateway</span>
|
||||
</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>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto mb-10">
|
||||
Stripe altyapısı ile projelerinize kolayca ödeme geçidi ekleyin. Merkezi yönetim paneli ile tüm işlemlerinizi tek bir yerden takip edin.
|
||||
<p className="text-xl text-gray-500 max-w-3xl mx-auto mb-14 font-medium leading-relaxed">
|
||||
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>
|
||||
|
||||
<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">
|
||||
<Link
|
||||
href={`/checkout?amount=${randomAmount}¤cy=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
|
||||
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>
|
||||
|
||||
<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
|
||||
</Link>
|
||||
<ArrowRight size={20} />
|
||||
</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>
|
||||
</header>
|
||||
|
||||
{/* Features */}
|
||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 border-t border-gray-50">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-600 w-12 h-12 rounded-2xl flex items-center justify-center text-white shadow-lg shadow-blue-200">
|
||||
<ShieldCheck size={24} />
|
||||
{/* Supported Gateways */}
|
||||
<section className="bg-[#F8FAFC] py-24">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<p className="text-[10px] font-black text-blue-600 uppercase tracking-[0.3em] mb-4">Desteklenen Altyapılar</p>
|
||||
<h2 className="text-3xl font-black text-gray-900 mb-16">Global Ödeme Çözümleri ile Tam Entegrasyon</h2>
|
||||
<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>
|
||||
<h3 className="text-xl font-bold text-gray-900">Güvenli Altyapı</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Stripe Elements kullanarak kart bilgilerini asla sunucularınızda saklamazsınız. Tam güvenlik garantisi.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-purple-600 w-12 h-12 rounded-2xl flex items-center justify-center text-white shadow-lg shadow-purple-200">
|
||||
<CreditCard size={24} />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900">Dinamik Ödeme</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Herhangi bir URL parametresi ile ödeme başlatın. Projelerinize entegre etmek sadece bir dakika sürer.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-orange-600 w-12 h-12 rounded-2xl flex items-center justify-center text-white shadow-lg shadow-orange-200">
|
||||
<LayoutDashboard size={24} />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-gray-900">Merkezi Takip</h3>
|
||||
<p className="text-gray-600 leading-relaxed">
|
||||
Tüm projelerinizden gelen ödemeleri tek bir admin panelinden, anlık grafikler ve raporlarla izleyin.
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 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 açı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 className="py-12 border-t border-gray-100 text-center">
|
||||
<p className="text-gray-400 text-sm">© 2026 froyd Payment Platforms. Tüm hakları saklıdır.</p>
|
||||
<footer className="py-20 border-t border-gray-50 text-center space-y-6">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
||||
146
components/admin/AddMerchantModal.tsx
Normal file
146
components/admin/AddMerchantModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
components/admin/AnalyticsBarChart.tsx
Normal file
81
components/admin/AnalyticsBarChart.tsx
Normal 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;
|
||||
}
|
||||
41
components/admin/CustomerSearch.tsx
Normal file
41
components/admin/CustomerSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
components/admin/QueryRangeSelector.tsx
Normal file
29
components/admin/QueryRangeSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
components/admin/TransactionChart.tsx
Normal file
87
components/admin/TransactionChart.tsx
Normal 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;
|
||||
}
|
||||
41
components/admin/TransactionSearch.tsx
Normal file
41
components/admin/TransactionSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
components/admin/TransactionStatusFilter.tsx
Normal file
40
components/admin/TransactionStatusFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
components/merchant/MerchantSidebar.tsx
Normal file
91
components/merchant/MerchantSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,14 @@ CREATE TABLE admin_users (
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Register initial admin (User should replace this or add via dashboard)
|
||||
-- INSERT INTO admin_users (email) VALUES ('your-email@example.com');
|
||||
-- Merchants (Firms) 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()
|
||||
);
|
||||
|
||||
-- Transactions table
|
||||
CREATE TABLE transactions (
|
||||
@@ -20,18 +26,27 @@ CREATE TABLE transactions (
|
||||
customer_name TEXT,
|
||||
customer_phone TEXT,
|
||||
callback_url TEXT,
|
||||
merchant_id UUID REFERENCES merchants(id),
|
||||
metadata JSONB DEFAULT '{}'::jsonb
|
||||
);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE transactions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE merchants ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Create policy for admins to read all
|
||||
CREATE POLICY "Admins can read all transactions" ON transactions
|
||||
FOR SELECT
|
||||
USING (auth.jwt() ->> 'email' IN (SELECT email FROM admin_users));
|
||||
|
||||
CREATE POLICY "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 "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)
|
||||
WITH CHECK (true);
|
||||
|
||||
17
docs/update_schema_merchants.sql
Normal file
17
docs/update_schema_merchants.sql
Normal 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);
|
||||
11
docs/update_schema_providers.sql
Normal file
11
docs/update_schema_providers.sql
Normal 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'))
|
||||
25
docs/update_schema_short_ids.sql
Normal file
25
docs/update_schema_short_ids.sql
Normal 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
78
lib/payment-providers.ts
Normal 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
9
lib/supabase-admin.ts
Normal 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!
|
||||
);
|
||||
@@ -4,8 +4,3 @@ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
||||
|
||||
export const supabaseAdmin = createClient(
|
||||
supabaseUrl,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||
);
|
||||
|
||||
401
package-lock.json
generated
401
package-lock.json
generated
@@ -18,6 +18,7 @@
|
||||
"next": "16.1.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.6.0",
|
||||
"stripe": "^20.1.2",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
@@ -1236,6 +1237,42 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -1243,6 +1280,18 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.4.1.tgz",
|
||||
@@ -1651,6 +1700,69 @@
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"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",
|
||||
"integrity": "sha512-YrT9ArrGaHForBaCNwFjoqJWmn8G1Pr7+BH/vwyLHciA9qT/wSiuOhxGCT50JA5xLvFBd6PIiGkE3afxcPE1nw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -1692,7 +1803,7 @@
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
@@ -1709,6 +1820,12 @@
|
||||
"@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": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
@@ -2795,9 +2912,130 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"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": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -3169,6 +3413,16 @@
|
||||
"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": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -3618,6 +3872,12 @@
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -4077,6 +4337,16 @@
|
||||
"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": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -4119,6 +4389,15 @@
|
||||
"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": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
@@ -5600,7 +5879,78 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"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": {
|
||||
"version": "1.0.10",
|
||||
@@ -5646,6 +5996,12 @@
|
||||
"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": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -6261,6 +6617,12 @@
|
||||
"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": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -6599,6 +6961,37 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"next": "16.1.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.6.0",
|
||||
"stripe": "^20.1.2",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user