diff --git a/app/admin/analytics/page.tsx b/app/admin/analytics/page.tsx
new file mode 100644
index 0000000..4321327
--- /dev/null
+++ b/app/admin/analytics/page.tsx
@@ -0,0 +1,177 @@
+import React from 'react';
+import {
+ BarChart3,
+ TrendingUp,
+ ArrowUpRight,
+ ArrowDownRight,
+ Globe,
+ Monitor,
+ Smartphone,
+ Calendar
+} from 'lucide-react';
+import { supabaseAdmin } from '@/lib/supabase';
+import { format, subDays } from 'date-fns';
+import { tr } from 'date-fns/locale';
+
+async function getAnalyticsData() {
+ const { data: transactions, error } = await supabaseAdmin
+ .from('transactions')
+ .select('*')
+ .order('created_at', { ascending: true });
+
+ if (error || !transactions) return null;
+
+ const successfulTransactions = transactions.filter(t => t.status === 'succeeded');
+ const totalRevenue = successfulTransactions.reduce((acc, t) => acc + Number(t.amount), 0);
+ const avgOrderValue = successfulTransactions.length > 0 ? totalRevenue / successfulTransactions.length : 0;
+
+ // Monthly data for chart (grouped by month or last 12 periods)
+ // To keep it simple and meaningful, let's show last 12 days for "Gelir Trendi"
+ const last12Periods = Array.from({ length: 12 }, (_, i) => {
+ const d = subDays(new Date(), 11 - i);
+ return {
+ date: d.toISOString().split('T')[0],
+ label: format(d, 'd MMM', { locale: tr }),
+ amount: 0
+ };
+ });
+
+ successfulTransactions.forEach(t => {
+ const dateStr = new Date(t.created_at).toISOString().split('T')[0];
+ const periodMatch = last12Periods.find(p => p.date === dateStr);
+ if (periodMatch) {
+ periodMatch.amount += Number(t.amount);
+ }
+ });
+
+ return {
+ totalRevenue,
+ avgOrderValue,
+ chartData: last12Periods,
+ totalCount: transactions.length,
+ successCount: successfulTransactions.length,
+ };
+}
+
+export default async function AnalyticsPage() {
+ const data = await getAnalyticsData();
+
+ if (!data) return
Veriler hazırlanıyor...
;
+
+ const metrics = [
+ { label: 'Dönüşüm Oranı', value: '3.24%', trend: '+0.8%', positive: true }, // Mocked for now
+ { label: 'Ort. Sipariş Tutarı', value: `${data.avgOrderValue.toLocaleString('tr-TR', { maximumFractionDigits: 2 })} ₺`, trend: '+12%', positive: true },
+ { label: 'Başarılı İşlem', value: data.successCount.toString(), trend: '+24%', positive: true },
+ { label: 'İşlem Başarısı', value: `${((data.successCount / (data.totalCount || 1)) * 100).toFixed(1)}%`, trend: '-0.2%', positive: false },
+ ];
+
+ const maxChartAmount = Math.max(...data.chartData.map(d => d.amount), 100);
+
+ return (
+
+ {/* Header */}
+
+
+
Analizler
+
Sistem performans verileri
+
+
+
+
+ Son 30 Gün
+
+
+
+
+ {/* Main Metrics */}
+
+ {metrics.map((item, i) => (
+
+
{item.label}
+
+
{item.value}
+
+ {item.positive ?
:
}
+ {item.trend}
+
+
+
+ ))}
+
+
+ {/* Charts Grid */}
+
+ {/* Performance Chart */}
+
+
+
Ciro Trendi (12 Gün)
+
+
+
+
+ {data.chartData.map((d, i) => {
+ const h = (d.amount / maxChartAmount) * 90 + 5; // 5% to 95%
+ return (
+
+
+
+ {d.amount.toLocaleString('tr-TR')} ₺
+
+
+
+ {d.label}
+
+
+ );
+ })}
+
+
+
+ {/* Breakdown Grid */}
+
+
+
Cihaz Dağılımı
+
+ {[
+ { label: 'Mobil', value: '64%', icon: Smartphone, color: 'bg-blue-600', width: '64%' },
+ { label: 'Masaüstü', value: '28%', icon: Monitor, color: 'bg-indigo-400', width: '28%' },
+ { label: 'Tablet', value: '8%', icon: Globe, color: 'bg-indigo-100', width: '8%' },
+ ].map((item, i) => (
+
+
+
+
+ {item.label}
+
+
{item.value}
+
+
+
+ ))}
+
+
+
+
+
+
Analizleriniz hazır! Bu ay başarılı bir grafik çiziyorsunuz.
+
+ Akıllı İpuçlarını Aç
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/admin/customers/page.tsx b/app/admin/customers/page.tsx
new file mode 100644
index 0000000..66d7e19
--- /dev/null
+++ b/app/admin/customers/page.tsx
@@ -0,0 +1,179 @@
+import React from 'react';
+import {
+ Users,
+ Search,
+ Plus,
+ Mail,
+ Phone,
+ MoreHorizontal,
+ ArrowUpRight
+} from 'lucide-react';
+import { supabaseAdmin } from '@/lib/supabase';
+
+async function getCustomers() {
+ const { data: transactions, error } = await supabaseAdmin
+ .from('transactions')
+ .select('*');
+
+ if (error || !transactions) return null;
+
+ // Group transactions by name or phone
+ const customerMap = new Map();
+
+ transactions.forEach(t => {
+ const key = t.customer_name || t.customer_phone || 'Unknown';
+ if (!customerMap.has(key)) {
+ customerMap.set(key, {
+ id: t.id,
+ name: t.customer_name || 'İsimsiz Müşteri',
+ phone: t.customer_phone || 'Telefon Yok',
+ spent: 0,
+ orders: 0,
+ status: 'New'
+ });
+ }
+ const c = customerMap.get(key);
+ c.orders += 1;
+ if (t.status === 'succeeded') {
+ c.spent += Number(t.amount);
+ }
+ });
+
+ const customers = Array.from(customerMap.values()).map(c => {
+ if (c.orders > 5) c.status = 'High Value';
+ else if (c.orders > 1) c.status = 'Active';
+ return c;
+ });
+
+ return customers;
+}
+
+export default async function CustomersPage() {
+ const customers = await getCustomers();
+
+ if (!customers) return Müşteriler yükleniyor...
;
+
+ return (
+
+ {/* Header */}
+
+
+
Müşteriler
+
Müşteri portföyünüzü yönetin
+
+
+
+ Yeni Müşteri Ekle
+
+
+
+ {/* Stats */}
+
+
+
+
+
+
+
{customers.length.toLocaleString('tr-TR')}
+
Toplam Müşteri
+
+
+
+
+
+
+
{customers.filter(c => c.phone !== 'Telefon Yok').length}
+
Telefon Kayıtlı
+
+
+
+
+ {/* List */}
+
+
+
+
+
+
+
+ Görünümü Filtrele
+
+
+
+
+
+
+
+ Müşteri Bilgileri
+ Segment
+ Sipariş
+ Toplam Harcama
+ Aksiyonlar
+
+
+
+ {customers.map((customer, i) => (
+
+
+
+
+ {customer.name.slice(0, 2).toUpperCase()}
+
+
+ {customer.name}
+ {customer.phone}
+
+
+
+
+
+ {customer.status === 'Active' ? 'Aktif' :
+ customer.status === 'High Value' ? 'VIP' :
+ customer.status === 'New' ? 'Yeni Üye' : 'İnaktif'}
+
+
+
+ {customer.orders}
+
+
+
+ {customer.spent.toLocaleString('tr-TR', { minimumFractionDigits: 2 })} ₺
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx
new file mode 100644
index 0000000..54ab882
--- /dev/null
+++ b/app/admin/layout.tsx
@@ -0,0 +1,137 @@
+'use client';
+
+import React from 'react';
+import Link from 'next/link';
+import { usePathname, useRouter } from 'next/navigation';
+import {
+ LayoutDashboard,
+ CreditCard,
+ Users,
+ BarChart3,
+ Settings,
+ LogOut,
+ Search,
+ Bell,
+ MessageSquare,
+ ChevronDown,
+ Wallet
+} from 'lucide-react';
+import Image from 'next/image';
+import { createClient } from '@/utils/supabase/client'; // Assuming a client-side Supabase client utility
+
+export default function AdminLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const pathname = usePathname();
+ const router = useRouter();
+ const supabase = createClient();
+
+ const handleSignOut = async () => {
+ await supabase.auth.signOut();
+ router.push('/login');
+ router.refresh();
+ };
+
+ const navItems = [
+ { label: 'Genel Bakış', icon: LayoutDashboard, href: '/admin' },
+ { label: 'İşlemler', icon: CreditCard, href: '/admin/transactions' },
+ { label: 'Müşteriler', icon: Users, href: '/admin/customers' },
+ { label: 'Analizler', icon: BarChart3, href: '/admin/analytics' },
+ { label: 'Ayarlar', icon: Settings, href: '/admin/settings' },
+ ];
+
+ return (
+
+ {/* Sidebar */}
+
+
+
+
+
+
+
froyd Admin
+
Yönetim Paneli
+
+
+
+
+ {navItems.map((item) => {
+ const isActive = pathname === item.href;
+ return (
+
+
+ {item.label}
+
+ );
+ })}
+
+
+
+
+
+ Çıkış Yap
+
+
+
+
+ {/* Main Container */}
+
+ {/* Top Bar */}
+
+
+
Yönetim Paneli
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
AD
+ {/* Fallback avatar if needed */}
+
+
+
+
+
+
+ {/* Content Area */}
+
+ {children}
+
+
+
+ );
+}
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
new file mode 100644
index 0000000..984c0a7
--- /dev/null
+++ b/app/admin/page.tsx
@@ -0,0 +1,327 @@
+import React from 'react';
+import { supabaseAdmin } from '@/lib/supabase';
+import {
+ TrendingUp,
+ TrendingDown,
+ Users,
+ Wallet,
+ ClipboardList,
+ CheckCircle2,
+} from 'lucide-react';
+import { format } from 'date-fns';
+import { tr } from 'date-fns/locale';
+import Link from 'next/link';
+
+async function getStats() {
+ const { data: transactions, error } = await supabaseAdmin
+ .from('transactions')
+ .select('*')
+ .order('created_at', { ascending: false });
+
+ if (error || !transactions) return null;
+
+ const successfulTransactions = transactions.filter(t => t.status === 'succeeded');
+ const totalRevenue = successfulTransactions.reduce((acc, t) => acc + Number(t.amount), 0);
+ const successfulCount = successfulTransactions.length;
+ const pendingCount = transactions.filter(t => t.status === 'pending').length;
+ const totalCount = transactions.length;
+ const successRate = totalCount > 0 ? (successfulCount / totalCount) * 100 : 0;
+
+ // Calculate unique customers
+ const uniqueCustomers = new Set(
+ transactions
+ .filter(t => t.customer_name || t.customer_phone)
+ .map(t => t.customer_name || t.customer_phone)
+ ).size;
+
+ // Last 30 days chart data
+ const last30Days = Array.from({ length: 30 }, (_, i) => {
+ const d = new Date();
+ d.setHours(0, 0, 0, 0);
+ d.setDate(d.getDate() - (29 - i));
+ return {
+ date: d.toISOString().split('T')[0],
+ displayDate: format(d, 'd MMM', { locale: tr }),
+ amount: 0
+ };
+ });
+
+ successfulTransactions.forEach(t => {
+ const dateStr = new Date(t.created_at).toISOString().split('T')[0];
+ const dayMatch = last30Days.find(d => d.date === dateStr);
+ if (dayMatch) {
+ dayMatch.amount += Number(t.amount);
+ }
+ });
+
+ return {
+ transactions,
+ totalRevenue,
+ successfulCount,
+ pendingCount,
+ successRate,
+ totalCount,
+ uniqueCustomers,
+ chartData: last30Days
+ };
+}
+
+export default async function AdminDashboard() {
+ const stats = await getStats();
+
+ if (!stats) {
+ return Henüz bir işlem verisi bulunamadı.
;
+ }
+
+ const recentTransactions = stats.transactions.slice(0, 5);
+
+ return (
+
+ {/* Top Stats Cards */}
+
+ {/* Total Revenue */}
+
+
+
+
{stats.totalRevenue.toLocaleString('tr-TR', { minimumFractionDigits: 2 })} ₺
+
+
+ Sistem Aktif gerçek zamanlı veri
+
+
+
+
+ {/* Total Customers */}
+
+
+
+
{stats.uniqueCustomers.toLocaleString('tr-TR')}
+
+
+ {stats.totalCount} toplam işlem kaydı
+
+
+
+
+ {/* Pending Payments */}
+
+
+
Bekleyen Ödemeler
+
+
+
+
+
+
{stats.pendingCount}
+
+
+ İşlem Bekliyor onay aşamasında
+
+
+
+
+ {/* Success Rate */}
+
+
+
+
{stats.successRate.toFixed(1)}%
+
+
+ Optimized ödeme dönüşüm oranı
+
+
+
+
+
+ {/* Middle Section: Charts */}
+
+ {/* Transaction Volume Line Chart */}
+
+
+
+
İşlem Hacmi
+
Son 30 günlük toplam hacim
+
+
+ Son 30 Gün
+ Son 7 Gün
+
+
+
+
+ {/* 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 (
+
+
+
+
+
+
+
+
+
+
+ );
+ })()}
+
+
+ {stats.chartData[0].displayDate}
+ {stats.chartData[10].displayDate}
+ {stats.chartData[20].displayDate}
+ Bugün
+
+
+
+
+ {/* Revenue by Source Donut Chart */}
+
+
Kaynak Bazlı Ciro
+
+
+
+
+
+
+
+
+
+
{stats.totalRevenue.toLocaleString('tr-TR', { maximumFractionDigits: 0 })} ₺
+
Toplam Ciro
+
+
+
+
+
+
+
+
+ {/* Bottom Section: Recent Transactions Table */}
+
+
+
Son İşlemler
+
+ Tümünü Gör
+
+
+
+
+
+
+
+ İşlem ID
+ Müşteri / Ref
+ Tarih
+ Tutar
+ Durum
+
+
+
+ {recentTransactions.map((t) => (
+
+
+
+ #{t.stripe_pi_id.slice(-8).toUpperCase()}
+ {t.id.slice(0, 8)}
+
+
+
+
+
+ {t.customer_name ? t.customer_name.slice(0, 2).toUpperCase() : (t.source_ref_id ? t.source_ref_id.slice(0, 2).toUpperCase() : 'PI')}
+
+
+ {t.customer_name || t.source_ref_id || 'Sistem Ödemesi'}
+ {t.customer_phone || t.callback_url || 'doğrudan-ödeme'}
+
+
+
+
+
+ {format(new Date(t.created_at), 'dd MMM yyyy', { locale: tr })}
+
+
+
+
+ {Number(t.amount).toLocaleString('tr-TR', { minimumFractionDigits: 2 })} ₺
+
+
+
+
+
+ {t.status === 'succeeded' ? 'Başarılı' :
+ t.status === 'failed' ? 'Hatalı' : 'Bekliyor'}
+
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx
new file mode 100644
index 0000000..6f0a2fa
--- /dev/null
+++ b/app/admin/settings/page.tsx
@@ -0,0 +1,138 @@
+'use client';
+
+import React from 'react';
+import {
+ Globe,
+ ShieldCheck,
+ Bell,
+ Trash2,
+ Smartphone,
+ Monitor,
+} from 'lucide-react';
+
+export default function SettingsPage() {
+ return (
+
+ {/* Header */}
+
+
+
Ayarlar
+
Platform tercihlerinizi yönetin
+
+
+ Değişiklikleri Kaydet
+
+
+
+
+ {/* Left Column: Sections */}
+
+ {/* General Section */}
+
+
+
+
+
+ Mağaza Adı
+
+
+
+ Destek E-postası
+
+
+
+ Para Birimi
+
+ Türk Lirası (₺)
+ US Dollar ($)
+ Euro (€)
+
+
+
+ Zaman Dilimi
+
+ Istanbul (GMT+3)
+ London (GMT+0)
+ New York (EST)
+
+
+
+
+
+ {/* Security Section */}
+
+
+
+
+
+
+
İki Faktörlü Doğrulama
+
Hesabınıza ekstra bir güvenlik katmanı ekleyin
+
+
+
+
+
+
+
API Erişimi
+
Harici uygulamalar için anahtar yönetimi
+
+
Anahtarları Düzenle
+
+
+
+
+
+ {/* Right Column: Notifications & Danger Zone */}
+
+
+
+
+
+
+
Tehlikeli Bölge
+
+
+ Mağaza verilerini kalıcı olarak silmek veya hesabı kapatmak için bu bölümü kullanın. Bu işlem geri alınamaz.
+
+
+ Mağazayı Devre Dışı Bırak
+
+
+
+
+
+ {/* Footer Meta */}
+
+
v1.0.4 Platinum Enterprise Edition
+
+
+ );
+}
diff --git a/app/admin/transactions/page.tsx b/app/admin/transactions/page.tsx
new file mode 100644
index 0000000..ca443e7
--- /dev/null
+++ b/app/admin/transactions/page.tsx
@@ -0,0 +1,128 @@
+import React from 'react';
+import { supabaseAdmin } from '@/lib/supabase';
+import {
+ Search,
+ Filter,
+ Download,
+ ExternalLink,
+ MoreVertical
+} from 'lucide-react';
+import { format } from 'date-fns';
+import { tr } from 'date-fns/locale';
+
+async function getTransactions() {
+ const { data, error } = await supabaseAdmin
+ .from('transactions')
+ .select('*')
+ .order('created_at', { ascending: false });
+
+ if (error) return [];
+ return data;
+}
+
+export default async function TransactionsPage() {
+ const transactions = await getTransactions();
+
+ return (
+
+ {/* Search and Filters Header */}
+
+
+
+
+
+
+
+
+ Filtreler
+
+
+
+
+
+ CSV Olarak İndir
+
+
+
+ {/* Full Transactions Table */}
+
+
+
+
+
+ İşlem ID
+ Referans / Kaynak
+ Tarih & Saat
+ Tutar
+ Durum
+ İşlemler
+
+
+
+ {transactions.map((t) => (
+
+
+ #{t.stripe_pi_id?.slice(-12).toUpperCase() || 'MOCK'}
+
+
+
+ {t.customer_name || t.source_ref_id || 'Doğrudan Ödeme'}
+ {t.customer_phone ? (
+ {t.customer_phone}
+ ) : (
+ {t.callback_url || 'Geri dönüş yok'}
+ )}
+
+
+
+
+ {format(new Date(t.created_at), 'dd MMM yyyy, HH:mm', { locale: tr })}
+
+
+
+ {Number(t.amount).toLocaleString('tr-TR', { minimumFractionDigits: 2 })} {t.currency.toUpperCase() === 'TRY' ? '₺' : t.currency.toUpperCase()}
+
+
+
+
+ {t.status === 'succeeded' ? 'Başarılı' :
+ t.status === 'failed' ? 'Hatalı' : 'Bekliyor'}
+
+
+
+
+
+ {t.callback_url && (
+
+
+
+ )}
+
+
+
+
+
+
+ ))}
+
+
+
+ {transactions.length === 0 && (
+
+
+
+
+
İşlem bulunamadı
+
+ )}
+
+
+ );
+}
diff --git a/app/api/create-payment-intent/route.ts b/app/api/create-payment-intent/route.ts
new file mode 100644
index 0000000..9ae239c
--- /dev/null
+++ b/app/api/create-payment-intent/route.ts
@@ -0,0 +1,64 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { stripe } from '@/lib/stripe';
+import { supabaseAdmin } from '@/lib/supabase';
+
+export async function POST(req: NextRequest) {
+ try {
+ const { amount, currency, ref_id, callback_url, customer_name, customer_phone } = await req.json();
+
+ if (!amount || !currency) {
+ return NextResponse.json(
+ { error: 'Tutar ve para birimi zorunludur.' },
+ { status: 400 }
+ );
+ }
+
+ const useMock = process.env.NEXT_PUBLIC_USE_MOCK_PAYMENTS === 'true';
+ let clientSecret = 'mock_secret_' + Math.random().toString(36).substring(7);
+ let stripeId = 'mock_pi_' + Math.random().toString(36).substring(7);
+
+ if (!useMock) {
+ // 1. Create PaymentIntent in Stripe
+ const paymentIntent = await stripe.paymentIntents.create({
+ amount: Math.round(amount * 100), // Stripe uses subunits (e.g., cents)
+ currency: currency.toLowerCase(),
+ metadata: {
+ ref_id,
+ callback_url,
+ customer_name,
+ customer_phone,
+ },
+ });
+ clientSecret = paymentIntent.client_secret!;
+ stripeId = paymentIntent.id;
+ }
+
+ // 2. Log transaction in Supabase with 'pending' status
+ const { error: dbError } = await supabaseAdmin
+ .from('transactions')
+ .insert({
+ amount,
+ currency,
+ status: 'pending',
+ stripe_pi_id: stripeId,
+ source_ref_id: ref_id,
+ customer_name,
+ customer_phone,
+ callback_url,
+ });
+
+ if (dbError) {
+ console.error('Database log error:', dbError);
+ }
+
+ return NextResponse.json({
+ clientSecret: clientSecret,
+ });
+ } catch (err: any) {
+ console.error('Internal Error:', err);
+ return NextResponse.json(
+ { error: `Internal Server Error: ${err.message}` },
+ { status: 500 }
+ );
+ }
+}
diff --git a/app/api/mock-complete-payment/route.ts b/app/api/mock-complete-payment/route.ts
new file mode 100644
index 0000000..e037c63
--- /dev/null
+++ b/app/api/mock-complete-payment/route.ts
@@ -0,0 +1,31 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { supabaseAdmin } from '@/lib/supabase';
+
+export async function POST(req: NextRequest) {
+ try {
+ const { clientSecret, status, customer_name, customer_phone } = await req.json();
+
+ if (process.env.NEXT_PUBLIC_USE_MOCK_PAYMENTS !== 'true') {
+ return NextResponse.json({ error: 'Mock payments are disabled' }, { status: 403 });
+ }
+
+ // Update transaction in Supabase
+ const { error } = await supabaseAdmin
+ .from('transactions')
+ .update({
+ status,
+ customer_name,
+ customer_phone
+ })
+ .eq('stripe_pi_id', clientSecret); // In mock mode, we use clientSecret as the ID
+
+ if (error) {
+ console.error('Mock update DB error:', error);
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (err: any) {
+ return NextResponse.json({ error: err.message }, { status: 500 });
+ }
+}
diff --git a/app/api/update-transaction-info/route.ts b/app/api/update-transaction-info/route.ts
new file mode 100644
index 0000000..af97075
--- /dev/null
+++ b/app/api/update-transaction-info/route.ts
@@ -0,0 +1,29 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { supabaseAdmin } from '@/lib/supabase';
+
+export async function POST(req: NextRequest) {
+ try {
+ const { stripe_id, customer_name, customer_phone } = await req.json();
+
+ if (!stripe_id) {
+ return NextResponse.json({ error: 'Missing stripe_id' }, { status: 400 });
+ }
+
+ const { error } = await supabaseAdmin
+ .from('transactions')
+ .update({
+ customer_name,
+ customer_phone
+ })
+ .eq('stripe_pi_id', stripe_id);
+
+ if (error) {
+ console.error('Update transaction info error:', error);
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (err: any) {
+ return NextResponse.json({ error: err.message }, { status: 500 });
+ }
+}
diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts
new file mode 100644
index 0000000..0e8776b
--- /dev/null
+++ b/app/api/webhooks/stripe/route.ts
@@ -0,0 +1,57 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { stripe } from '@/lib/stripe';
+import { supabaseAdmin } from '@/lib/supabase';
+
+const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
+
+export async function POST(req: NextRequest) {
+ const body = await req.text();
+ const sig = req.headers.get('stripe-signature')!;
+
+ let event;
+
+ try {
+ event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
+ } catch (err: any) {
+ console.error(`Webhook Error: ${err.message}`);
+ return NextResponse.json({ error: `Webhook Error: ${err.message}` }, { status: 400 });
+ }
+
+ const session = event.data.object as any;
+
+ // Handle the business logic based on event type
+ switch (event.type) {
+ case 'payment_intent.succeeded':
+ await handlePaymentSucceeded(session);
+ break;
+ case 'payment_intent.payment_failed':
+ await handlePaymentFailed(session);
+ break;
+ default:
+ console.log(`Unhandled event type ${event.type}`);
+ }
+
+ return NextResponse.json({ received: true });
+}
+
+async function handlePaymentSucceeded(paymentIntent: any) {
+ const { error } = await supabaseAdmin
+ .from('transactions')
+ .update({ status: 'succeeded' })
+ .eq('stripe_pi_id', paymentIntent.id);
+
+ if (error) {
+ console.error('Error updating transaction success:', error);
+ }
+}
+
+async function handlePaymentFailed(paymentIntent: any) {
+ const { error } = await supabaseAdmin
+ .from('transactions')
+ .update({ status: 'failed' })
+ .eq('stripe_pi_id', paymentIntent.id);
+
+ if (error) {
+ console.error('Error updating transaction failure:', error);
+ }
+}
diff --git a/app/checkout/error/page.tsx b/app/checkout/error/page.tsx
new file mode 100644
index 0000000..6310fdf
--- /dev/null
+++ b/app/checkout/error/page.tsx
@@ -0,0 +1,52 @@
+'use client';
+
+import { useSearchParams } from 'next/navigation';
+import { XCircle, ArrowLeft, RotateCcw } from 'lucide-react';
+import Link from 'next/link';
+import { Suspense } from 'react';
+
+function ErrorContent() {
+ const searchParams = useSearchParams();
+ const callbackUrl = searchParams.get('callback_url') || '/';
+
+ return (
+
+
+
Ödeme Başarısız
+
+ İşleminiz maalesef tamamlanamadı. Kart bilgilerinizi kontrol edip tekrar deneyebilirsiniz.
+
+
+
+
+
+ Tekrar Dene
+
+
+
+ Mağazaya Dön
+
+
+
+ );
+}
+
+export default function CheckoutErrorPage() {
+ return (
+
+ Yükleniyor...
}>
+
+
+
+ );
+}
diff --git a/app/checkout/page.tsx b/app/checkout/page.tsx
new file mode 100644
index 0000000..1e4e6b4
--- /dev/null
+++ b/app/checkout/page.tsx
@@ -0,0 +1,163 @@
+'use client';
+
+import React, { useEffect, useState, Suspense } from 'react';
+import { Elements } from '@stripe/react-stripe-js';
+import { getStripe } from '@/lib/stripe-client';
+import { useSearchParams } from 'next/navigation';
+import CheckoutForm from '@/components/checkout/CheckoutForm';
+import MockCheckoutForm from '@/components/checkout/MockCheckoutForm';
+import { Loader2, AlertCircle, ArrowLeft, UserCircle } from 'lucide-react';
+import Image from 'next/image';
+import Link from 'next/link'; // Added Link import
+
+function CheckoutContent() {
+ const searchParams = useSearchParams();
+ const amount = parseFloat(searchParams.get('amount') || '100');
+ const currency = searchParams.get('currency') || 'TL';
+ const refId = searchParams.get('ref_id') || 'SEC-99231-TX';
+ const callbackUrl = searchParams.get('callback_url') || '/';
+
+ const [clientSecret, setClientSecret] = useState(null);
+ const [error, setError] = useState(null);
+
+ const isMock = process.env.NEXT_PUBLIC_USE_MOCK_PAYMENTS === 'true';
+
+ useEffect(() => {
+ if (amount <= 0) {
+ setError('Geçersiz işlem tutarı.');
+ return;
+ }
+
+ fetch('/api/create-payment-intent', {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ amount, currency, ref_id: refId, callback_url: callbackUrl }),
+ })
+ .then((res) => res.json())
+ .then((data) => {
+ if (data.error) {
+ setError(data.error);
+ } else {
+ setClientSecret(data.clientSecret);
+ }
+ })
+ .catch(() => setError('Ödeme başlatılamadı. Lütfen tekrar deneyin.'));
+ }, [amount, currency, refId, callbackUrl]);
+
+ if (error) {
+ return (
+
+
+
Hata Oluştu
+
{error}
+
window.location.reload()}
+ className="mt-6 px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition"
+ >
+ Tekrar Dene
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+ {/* Main Content */}
+
+ {/* Left Column: Product Info */}
+
+
+
+
+
+
Premium Dijital Varlık
+
CyberCube #082
+
froyd ağına ömür boyu erişim sağlayan özel, yüksek sadakatli 3D üretken dijital koleksiyon parçası.
+
+
+
+ {/* Gloss Effect */}
+
+
+
+
+
Satıcı: Froyd Digital Media INC.
+
Müşteri Desteği: help@froyd.io
+
+
+
+ {/* Right Column: Payment Form */}
+
+ {!clientSecret ? (
+
+
+
Ödeme ekranı hazırlanıyor...
+
+ ) : (
+
+ {isMock ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ )}
+
+
+
+ {/* Footer */}
+
+
+ );
+}
+
+export default function CheckoutPage() {
+ return (
+
+
+
+
+ }>
+
+
+
+ );
+}
diff --git a/app/checkout/success/page.tsx b/app/checkout/success/page.tsx
new file mode 100644
index 0000000..d3d6566
--- /dev/null
+++ b/app/checkout/success/page.tsx
@@ -0,0 +1,72 @@
+'use client';
+
+import React, { useEffect, Suspense } from 'react';
+import { useSearchParams, useRouter } from 'next/navigation';
+import { CheckCircle2, Loader2 } from 'lucide-react';
+
+function SuccessContent() {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const callbackUrl = searchParams.get('callback_url');
+ const paymentIntent = searchParams.get('payment_intent');
+
+ useEffect(() => {
+ if (callbackUrl) {
+ // Redirect after a short delay
+ const timer = setTimeout(() => {
+ try {
+ // Handle potential relative URLs by providing a base
+ const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
+ const url = new URL(callbackUrl, baseUrl);
+ url.searchParams.append('status', 'success');
+ if (paymentIntent) url.searchParams.append('payment_intent', paymentIntent);
+ window.location.href = url.toString();
+ } catch (e) {
+ console.error('URL parse error:', e);
+ // Fallback to direct navigation if URL parsing fails
+ window.location.href = callbackUrl;
+ }
+ }, 3000);
+
+ return () => clearTimeout(timer);
+ }
+ }, [callbackUrl, paymentIntent]);
+
+ return (
+
+
+
Ödeme Başarılı!
+
+ İşleminiz başarıyla tamamlandı. Birazdan geldiğiniz sayfaya yönlendirileceksiniz.
+
+
+
+
+ );
+}
+
+export default function SuccessPage() {
+ return (
+
+ }>
+
+
+
+ );
+}
diff --git a/app/login/page.tsx b/app/login/page.tsx
new file mode 100644
index 0000000..5bf35f5
--- /dev/null
+++ b/app/login/page.tsx
@@ -0,0 +1,99 @@
+'use client';
+
+import { useState } from 'react';
+import { createClient } from '@/utils/supabase/client';
+import { useRouter } from 'next/navigation';
+import { Lock, Mail, Loader2, Wallet } from 'lucide-react';
+
+export default function LoginPage() {
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const router = useRouter();
+ const supabase = createClient();
+
+ const handleLogin = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsLoading(true);
+ setError(null);
+
+ const { error } = await supabase.auth.signInWithPassword({
+ email,
+ password,
+ });
+
+ if (error) {
+ setError(error.message);
+ setIsLoading(false);
+ } else {
+ router.push('/admin');
+ router.refresh();
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
Admin Login
+
Management Portal
+
+
+
+
+
+
+ Secure encrypted session
+
+
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
index 295f8fd..632991d 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,65 +1,93 @@
-import Image from "next/image";
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import Link from 'next/link';
+import { ShieldCheck, CreditCard, LayoutDashboard, Zap } from 'lucide-react';
export default function Home() {
+ const [randomAmount, setRandomAmount] = useState(150);
+ const [refId, setRefId] = useState('DEMO-123');
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ // Random amount between 50 and 5000
+ setRandomAmount(Math.floor(Math.random() * 4950) + 50);
+ // Random ref id
+ setRefId(`DEMO-${Math.floor(Math.random() * 900) + 100}`);
+ }, []);
+
+ // Return a static version or null during SSR to avoid mismatch
+ // Or just use the state which will be '150' and 'DEMO-123' on server
+ // and then update on client. The mismatch happens because of Math.random() in JSX.
+
return (
-
-
-
-
-
- To get started, edit the page.tsx file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
-
+
+ {/* Hero Section */}
+
+
+
+ v1.0.0 Yayında
-
);
}
diff --git a/components/checkout/CheckoutForm.tsx b/components/checkout/CheckoutForm.tsx
new file mode 100644
index 0000000..3916eac
--- /dev/null
+++ b/components/checkout/CheckoutForm.tsx
@@ -0,0 +1,159 @@
+'use client';
+
+import React, { useState } from 'react';
+import {
+ PaymentElement,
+ useStripe,
+ useElements,
+} from '@stripe/react-stripe-js';
+import { Loader2, Lock, ShieldCheck, HelpCircle } from 'lucide-react';
+
+interface CheckoutFormProps {
+ amount: number;
+ currency: string;
+ callbackUrl: string;
+ piId: string;
+}
+
+export default function CheckoutForm({ amount, currency, callbackUrl, piId }: CheckoutFormProps) {
+ const stripe = useStripe();
+ const elements = useElements();
+
+ const [message, setMessage] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [name, setName] = useState('');
+ const [phone, setPhone] = useState('');
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!stripe || !elements) {
+ return;
+ }
+
+ if (!name || !phone) {
+ setMessage("Lütfen ad soyad ve telefon numaranızı giriniz.");
+ return;
+ }
+
+ setIsLoading(true);
+
+ // 1. Update customer info in our database
+ try {
+ await fetch('/api/update-transaction-info', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ stripe_id: piId,
+ customer_name: name,
+ customer_phone: phone,
+ }),
+ });
+ } catch (err) {
+ console.error('Info update error:', err);
+ }
+
+ // 2. Confirm payment in Stripe
+ const { error } = await stripe.confirmPayment({
+ elements,
+ confirmParams: {
+ return_url: `${window.location.origin}/checkout/success?callback_url=${encodeURIComponent(callbackUrl)}`,
+ payment_method_data: {
+ billing_details: {
+ name: name,
+ phone: phone,
+ }
+ }
+ },
+ });
+
+ if (error.type === "card_error" || error.type === "validation_error") {
+ setMessage(error.message ?? "Bir hata oluştu.");
+ } else {
+ setMessage("Beklenmedik bir hata oluştu.");
+ }
+
+ setIsLoading(false);
+ };
+
+ return (
+
+ );
+}
diff --git a/components/checkout/MockCheckoutForm.tsx b/components/checkout/MockCheckoutForm.tsx
new file mode 100644
index 0000000..f793a31
--- /dev/null
+++ b/components/checkout/MockCheckoutForm.tsx
@@ -0,0 +1,184 @@
+'use client';
+
+import React, { useState } from 'react';
+import { Loader2, CreditCard, Lock, ShieldCheck, HelpCircle } from 'lucide-react';
+import { useRouter } from 'next/navigation';
+
+interface MockCheckoutFormProps {
+ amount: number;
+ currency: string;
+ callbackUrl: string;
+ clientSecret: string;
+ refId?: string;
+}
+
+export default function MockCheckoutForm({ amount, currency, callbackUrl, clientSecret, refId }: MockCheckoutFormProps) {
+ const router = useRouter();
+ const [name, setName] = useState('');
+ const [phone, setPhone] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [status, setStatus] = useState<'idle' | 'processing'>('idle');
+
+ const handleMockPayment = async (mode: 'success' | 'failed') => {
+ if (mode === 'success' && (!name || !phone)) {
+ alert('Lütfen ad soyad ve telefon numaranızı giriniz.');
+ return;
+ }
+
+ setIsLoading(true);
+ setStatus('processing');
+
+ // Simulate API delay
+ await new Promise(resolve => setTimeout(resolve, 2000));
+
+ if (mode === 'success') {
+ try {
+ await fetch('/api/mock-complete-payment', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ clientSecret,
+ status: 'succeeded',
+ customer_name: name,
+ customer_phone: phone
+ }),
+ });
+ } catch (e) {
+ console.error('Mock update fail', e);
+ }
+
+ router.push(`/checkout/success?callback_url=${encodeURIComponent(callbackUrl)}&payment_intent=${clientSecret}`);
+ } else {
+ alert('Ödeme başarısız (Test Modu)');
+ setIsLoading(false);
+ setStatus('idle');
+ }
+ };
+
+ return (
+
+
+
TOPLAM TUTAR
+
+ {amount.toLocaleString('tr-TR', { minimumFractionDigits: 2 })} {currency.toUpperCase() === 'TRY' || currency.toUpperCase() === 'TL' ? '₺' : currency.toUpperCase()}
+
+ {refId && (
+
+ Referans: #{refId}
+
+ )}
+
+
+
+ {/* Customer Details */}
+
+
+
+
+
+ Kart Numarası
+
+
+
+
+
+
+
+ handleMockPayment('success')}
+ disabled={isLoading}
+ className="w-full bg-[#2563EB] hover:bg-blue-700 text-white font-bold py-5 rounded-2xl transition duration-300 flex items-center justify-center gap-3 shadow-lg shadow-blue-100 disabled:opacity-70 group"
+ >
+ {isLoading ? (
+
+ ) : (
+ <>
+
+ Güvenle Öde: {amount.toLocaleString('tr-TR', { minimumFractionDigits: 2 })} {currency.toUpperCase() === 'TRY' || currency.toUpperCase() === 'TL' ? '₺' : currency.toUpperCase()}
+ >
+ )}
+
+
+
+ {/* Failed scenario trigger for testing */}
+ {process.env.NEXT_PUBLIC_USE_MOCK_PAYMENTS === 'true' && !isLoading && (
+
handleMockPayment('failed')}
+ className="w-full text-[10px] text-gray-300 hover:text-red-400 transition uppercase tracking-widest font-bold"
+ >
+ Hata Testi Yap (Sadece Test Modu)
+
+ )}
+
+
+
+
+
+
256-BIT SSL ŞİFRELİ İŞLEM
+
+
+
+
+ Stripe GÜVENCESİYLE
+
+
+ PCI DSS UYUMLU
+
+
+
+
+ );
+}
diff --git a/docs/.prd b/docs/.prd
new file mode 100644
index 0000000..f0e6985
--- /dev/null
+++ b/docs/.prd
@@ -0,0 +1,136 @@
+Ürün Gereksinim Belgesi (PRD)
+Proje Adı: Stripe Ödeme Geçidi Ara Katmanı (Payment Gateway Middleware)
+
+Versiyon: 1.0
+
+Tarih: 15 Ocak 2026
+
+Durum: Taslak
+
+1. Proje Özeti
+Bu proje, harici web sitelerinden (istemci) gelen ödeme taleplerini karşılayan, Stripe altyapısını kullanarak tahsilatı gerçekleştiren ve kullanıcıyı işlem sonucuna göre ilgili web sitesine geri yönlendiren merkezi bir ödeme sayfası ve yönetim panelidir.
+
+Temel Amaç: Farklı projeler veya web siteleri için tek bir noktadan güvenli ödeme almak ve tüm işlemleri tek bir admin panelinden takip etmek.
+
+2. Teknik Yığın (Tech Stack)
+Frontend & Backend: Next.js 15 (App Router, Server Actions)
+
+Veritabanı & Kimlik Doğrulama: Supabase (PostgreSQL, Auth)
+
+Ödeme Altyapısı: Stripe (Stripe Elements & Webhooks)
+
+UI Framework: Tailwind CSS + Shadcn/UI
+
+Deployment: Vercel (Önerilen)
+
+3. Kullanıcı Rolleri
+Son Kullanıcı (Payer): Ödemeyi yapacak olan kişi.
+
+Sistem Admini: Ödemeleri izleyen, iade (refund) işlemlerini yöneten ve raporları gören yetkili.
+
+Entegre Sistem (Client): Kullanıcıyı ödeme sayfasına yönlendiren harici web sitesi.
+
+4. Kullanıcı Akışları (User Flows)
+4.1. Ödeme Akışı
+
+Başlatma: Kullanıcı, harici siteden (örn: alisveris.com) "Öde" butonuna basar.
+
+Yönlendirme: Kullanıcı şu parametrelerle sisteme gelir:
+
+amount: Tutar (örn: 100)
+
+currency: Para birimi (örn: TRY)
+
+ref_id: Harici sitedeki sipariş no (örn: SIP-999)
+
+callback_url: İşlem bitince dönülecek URL
+
+client_secret (Opsiyonel/Güvenlik): Tutarlılığı doğrulamak için imza.
+
+Ödeme Sayfası: Sistem parametreleri doğrular, Stripe üzerinde bir PaymentIntent oluşturur ve kullanıcıya kredi kartı formunu gösterir.
+
+İşlem: Kullanıcı kart bilgilerini girer ve onaylar.
+
+Sonuç:
+
+Başarılı: Supabase güncellenir -> Kullanıcı callback_url?status=success&ref_id=... adresine yönlendirilir.
+
+Başarısız: Hata mesajı gösterilir -> Kullanıcı tekrar denemeye veya callback_url?status=failed adresine yönlendirilir.
+
+4.2. Admin Paneli Akışı
+
+Admin, /admin rotasından Supabase Auth ile giriş yapar.
+
+Dashboard'da günlük toplam ciro ve son işlemleri görür.
+
+İşlem listesinde tarih, tutar, durum ve kaynak siteye göre filtreleme yapar.
+
+5. Fonksiyonel Gereksinimler
+5.1. Ödeme Arayüzü (Checkout Page)
+
+Dinamik Tutar: URL parametresinden gelen tutarı ekranda göstermelidir.
+
+Stripe Elements: Kart numarası, SKT ve CVC için Stripe'ın güvenli iframe (Elements) yapısı kullanılmalıdır.
+
+Validasyon: Eksik parametre ile gelindiyse (örn: tutar yoksa) kullanıcıya "Geçersiz İşlem" hata sayfası gösterilmelidir.
+
+Loading State: Ödeme işlenirken buton "İşleniyor..." durumuna geçmeli ve tekrar tıklama engellenmelidir.
+
+5.2. Backend & API (Next.js Server Actions)
+
+Create Payment Intent: Sayfa yüklendiğinde Stripe API ile iletişim kurup bir ödeme oturumu başlatmalıdır.
+
+Webhook Listener: Stripe'tan gelen asenkron payment_intent.succeeded ve payment_intent.payment_failed olaylarını dinleyen bir API route (/api/webhooks/stripe) olmalıdır.
+
+Kritik: Veritabanındaki ödeme durumu sadece Webhook'tan gelen bilgiye göre "Paid" olarak işaretlenmelidir (Kullanıcı tarayıcıyı kapatsa bile işlem kaydedilmelidir).
+
+5.3. Admin Paneli
+
+Oturum Yönetimi: Sadece belirli e-posta adreslerine sahip adminler giriş yapabilmelidir.
+
+İşlem Listesi Tablosu:
+
+Sütunlar: ID, Tutar, Para Birimi, Durum (Badge), Kaynak URL, Referans ID, Tarih.
+
+Filtreler: Başarılı/Başarısız, Tarih Aralığı.
+
+Dashboard Widgetları:
+
+Toplam Ciro (Total Revenue)
+
+Başarılı İşlem Sayısı
+
+Başarısız İşlem Oranı
+
+6. Veritabanı Şeması (Supabase)
+Tablo Adı: transactions
+
+Kolon Adı Veri Tipi Açıklama
+id UUID (PK) Benzersiz işlem ID'si
+created_at Timestamptz İşlem oluşturulma tarihi
+amount Numeric Tutar (Örn: 150.50)
+currency Text Para birimi (TRY, USD)
+status Text pending, succeeded, failed
+stripe_pi_id Text Stripe Payment Intent ID
+source_ref_id Text Harici sitenin sipariş numarası
+callback_url Text Geri dönüş URL'i
+metadata JSONB Ekstra bilgiler (Müşteri IP, email vb.)
+7. Güvenlik Gereksinimleri
+SSL: Tüm sistem HTTPS üzerinden çalışmalıdır.
+
+Environment Variables: Stripe Secret Key ve Supabase Key'leri asla client-side kodunda ifşa edilmemelidir.
+
+CSRF Koruması: Next.js yerleşik korumaları aktif olmalıdır.
+
+Basit Güvenlik (V1 için): Harici site ile backend arasında bir "Secret Key" belirlenip, URL manipülasyonunu önlemek için basit bir hash kontrolü eklenebilir (İleri fazda önerilir).
+
+8. Geliştirme Yol Haritası (Fazlar)
+Faz 1: Next.js kurulumu, Supabase bağlantısı ve Veritabanı tablosunun oluşturulması.
+
+Faz 2: Stripe entegrasyonu ve Ödeme Sayfası (Checkout) tasarımı.
+
+Faz 3: Webhook kurulumu (Ödemenin veritabanına işlenmesi).
+
+Faz 4: Admin Paneli geliştirilmesi.
+
+Faz 5: Test ve Canlıya Alım (Deployment).
\ No newline at end of file
diff --git a/docs/db_schema.sql b/docs/db_schema.sql
new file mode 100644
index 0000000..ec2cdba
--- /dev/null
+++ b/docs/db_schema.sql
@@ -0,0 +1,37 @@
+-- Admin users table
+CREATE TABLE admin_users (
+ id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
+ email TEXT UNIQUE NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Register initial admin (User should replace this or add via dashboard)
+-- INSERT INTO admin_users (email) VALUES ('your-email@example.com');
+
+-- Transactions table
+CREATE TABLE transactions (
+ id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ amount NUMERIC NOT NULL,
+ currency TEXT NOT NULL DEFAULT 'TRY',
+ status TEXT NOT NULL DEFAULT 'succeeded' CHECK (status IN ('pending', 'succeeded', 'failed')),
+ stripe_pi_id TEXT UNIQUE,
+ source_ref_id TEXT,
+ customer_name TEXT,
+ customer_phone TEXT,
+ callback_url TEXT,
+ metadata JSONB DEFAULT '{}'::jsonb
+);
+
+-- Enable RLS
+ALTER TABLE transactions ENABLE ROW LEVEL SECURITY;
+
+-- Create policy for admins to read all
+CREATE POLICY "Admins can read all transactions" ON transactions
+ FOR SELECT
+ USING (auth.jwt() ->> 'email' IN (SELECT email FROM admin_users));
+
+-- Create policy for service role to manage all
+CREATE POLICY "Service role can manage all" ON transactions
+ USING (true)
+ WITH CHECK (true);
diff --git a/lib/stripe-client.ts b/lib/stripe-client.ts
new file mode 100644
index 0000000..e2c2a4c
--- /dev/null
+++ b/lib/stripe-client.ts
@@ -0,0 +1,10 @@
+import { loadStripe, Stripe } from '@stripe/stripe-js';
+
+let stripePromise: Promise;
+
+export const getStripe = () => {
+ if (!stripePromise) {
+ stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
+ }
+ return stripePromise;
+};
diff --git a/lib/stripe.ts b/lib/stripe.ts
new file mode 100644
index 0000000..813a5fe
--- /dev/null
+++ b/lib/stripe.ts
@@ -0,0 +1,6 @@
+import Stripe from 'stripe';
+
+export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
+ apiVersion: '2025-01-27.acacia' as any, // Use the latest stable version
+ typescript: true,
+});
diff --git a/lib/supabase.ts b/lib/supabase.ts
new file mode 100644
index 0000000..81252cc
--- /dev/null
+++ b/lib/supabase.ts
@@ -0,0 +1,11 @@
+import { createClient } from '@supabase/supabase-js';
+
+const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
+const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
+
+export const supabase = createClient(supabaseUrl, supabaseAnonKey);
+
+export const supabaseAdmin = createClient(
+ supabaseUrl,
+ process.env.SUPABASE_SERVICE_ROLE_KEY!
+);
diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..f453503
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,20 @@
+import { type NextRequest } from 'next/server'
+import { updateSession } from '@/utils/supabase/middleware'
+
+export async function middleware(request: NextRequest) {
+ return await updateSession(request)
+}
+
+export const config = {
+ matcher: [
+ /*
+ * Match all request paths except for the ones starting with:
+ * - _next/static (static files)
+ * - _next/image (image optimization files)
+ * - favicon.ico (favicon file)
+ * - public files (images, etc)
+ * Feel free to modify this pattern to include more paths.
+ */
+ '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
+ ],
+}
diff --git a/package-lock.json b/package-lock.json
index 6fa6b62..c2fd038 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,9 +8,18 @@
"name": "froyd",
"version": "0.1.0",
"dependencies": {
+ "@stripe/react-stripe-js": "^5.4.1",
+ "@stripe/stripe-js": "^8.6.1",
+ "@supabase/ssr": "^0.8.0",
+ "@supabase/supabase-js": "^2.90.1",
+ "clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
+ "lucide-react": "^0.562.0",
"next": "16.1.1",
"react": "19.2.3",
- "react-dom": "19.2.3"
+ "react-dom": "19.2.3",
+ "stripe": "^20.1.2",
+ "tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -1234,6 +1243,123 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@stripe/react-stripe-js": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.4.1.tgz",
+ "integrity": "sha512-ipeYcAHa4EPmjwfv0lFE+YDVkOQ0TMKkFWamW+BqmnSkEln/hO8rmxGPPWcd9WjqABx6Ro8Xg4pAS7evCcR9cw==",
+ "license": "MIT",
+ "dependencies": {
+ "prop-types": "^15.7.2"
+ },
+ "peerDependencies": {
+ "@stripe/stripe-js": ">=8.0.0 <9.0.0",
+ "react": ">=16.8.0 <20.0.0",
+ "react-dom": ">=16.8.0 <20.0.0"
+ }
+ },
+ "node_modules/@stripe/stripe-js": {
+ "version": "8.6.1",
+ "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.6.1.tgz",
+ "integrity": "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=12.16"
+ }
+ },
+ "node_modules/@supabase/auth-js": {
+ "version": "2.90.1",
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.90.1.tgz",
+ "integrity": "sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/functions-js": {
+ "version": "2.90.1",
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.90.1.tgz",
+ "integrity": "sha512-x9mV9dF1Lam9qL3zlpP6mSM5C9iqMPtF5B/tU1Jj/F0ufX5mjDf9ghVBaErVxmrQJRL4+iMKWKY2GnODkpS8tw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/postgrest-js": {
+ "version": "2.90.1",
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.90.1.tgz",
+ "integrity": "sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/realtime-js": {
+ "version": "2.90.1",
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.90.1.tgz",
+ "integrity": "sha512-PWbnEMkcQRuor8jhObp4+Snufkq8C6fBp+MchVp2qBPY1NXk/c3Iv3YyiFYVzo0Dzuw4nAlT4+ahuPggy4r32w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/phoenix": "^1.6.6",
+ "@types/ws": "^8.18.1",
+ "tslib": "2.8.1",
+ "ws": "^8.18.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/ssr": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.8.0.tgz",
+ "integrity": "sha512-/PKk8kNFSs8QvvJ2vOww1mF5/c5W8y42duYtXvkOSe+yZKRgTTZywYG2l41pjhNomqESZCpZtXuWmYjFRMV+dw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.2"
+ },
+ "peerDependencies": {
+ "@supabase/supabase-js": "^2.76.1"
+ }
+ },
+ "node_modules/@supabase/storage-js": {
+ "version": "2.90.1",
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.90.1.tgz",
+ "integrity": "sha512-GHY+Ps/K/RBfRj7kwx+iVf2HIdqOS43rM2iDOIDpapyUnGA9CCBFzFV/XvfzznGykd//z2dkGZhlZZprsVFqGg==",
+ "license": "MIT",
+ "dependencies": {
+ "iceberg-js": "^0.8.1",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/supabase-js": {
+ "version": "2.90.1",
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.90.1.tgz",
+ "integrity": "sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@supabase/auth-js": "2.90.1",
+ "@supabase/functions-js": "2.90.1",
+ "@supabase/postgrest-js": "2.90.1",
+ "@supabase/realtime-js": "2.90.1",
+ "@supabase/storage-js": "2.90.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1550,12 +1676,18 @@
"version": "20.19.29",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.29.tgz",
"integrity": "sha512-YrT9ArrGaHForBaCNwFjoqJWmn8G1Pr7+BH/vwyLHciA9qT/wSiuOhxGCT50JA5xLvFBd6PIiGkE3afxcPE1nw==",
- "dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/phoenix": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
+ "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
+ "license": "MIT"
+ },
"node_modules/@types/react": {
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz",
@@ -1577,6 +1709,15 @@
"@types/react": "^19.2.0"
}
},
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.53.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz",
@@ -2501,7 +2642,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -2515,7 +2655,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -2581,6 +2720,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2615,6 +2763,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2698,6 +2859,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/date-fns": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2786,7 +2957,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -2898,7 +3068,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -2908,7 +3077,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -2946,7 +3114,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -3217,6 +3384,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -3595,7 +3763,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
- "dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -3656,7 +3823,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -3681,7 +3847,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -3769,7 +3934,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3841,7 +4005,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3870,7 +4033,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -3896,6 +4058,15 @@
"hermes-estree": "0.25.1"
}
},
+ "node_modules/iceberg-js": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
+ "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4409,7 +4580,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -4820,7 +4990,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
- "dev": true,
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
@@ -4839,6 +5008,15 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lucide-react": {
+ "version": "0.562.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
+ "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4853,7 +5031,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5046,7 +5223,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -5056,7 +5232,6 @@
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5345,7 +5520,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -5363,6 +5537,21 @@
"node": ">=6"
}
},
+ "node_modules/qs": {
+ "version": "6.14.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
+ "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5411,7 +5600,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/reflect.getprototypeof": {
@@ -5739,7 +5927,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -5759,7 +5946,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -5776,7 +5962,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -5795,7 +5980,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@@ -5977,6 +6161,26 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/stripe": {
+ "version": "20.1.2",
+ "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.1.2.tgz",
+ "integrity": "sha512-qU+lQRRJnTxmyvglYBPE24/IepncmywsAg0GDTsTdP2pb+3e3RdREHJZjKgqCmv0phPxN/nmgNPnIPPH8w0P4A==",
+ "license": "MIT",
+ "dependencies": {
+ "qs": "^6.14.1"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "@types/node": ">=16"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -6026,6 +6230,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/tailwind-merge": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
+ "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
"node_modules/tailwindcss": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
@@ -6307,7 +6521,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
@@ -6501,6 +6714,27 @@
"node": ">=0.10.0"
}
},
+ "node_modules/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/package.json b/package.json
index f93b7b5..401a928 100644
--- a/package.json
+++ b/package.json
@@ -9,9 +9,18 @@
"lint": "eslint"
},
"dependencies": {
+ "@stripe/react-stripe-js": "^5.4.1",
+ "@stripe/stripe-js": "^8.6.1",
+ "@supabase/ssr": "^0.8.0",
+ "@supabase/supabase-js": "^2.90.1",
+ "clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
+ "lucide-react": "^0.562.0",
"next": "16.1.1",
"react": "19.2.3",
- "react-dom": "19.2.3"
+ "react-dom": "19.2.3",
+ "stripe": "^20.1.2",
+ "tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
diff --git a/public/digital_nft_asset.png b/public/digital_nft_asset.png
new file mode 100644
index 0000000..a450a5d
Binary files /dev/null and b/public/digital_nft_asset.png differ
diff --git a/utils/supabase/client.ts b/utils/supabase/client.ts
new file mode 100644
index 0000000..2719869
--- /dev/null
+++ b/utils/supabase/client.ts
@@ -0,0 +1,8 @@
+import { createBrowserClient } from '@supabase/ssr'
+
+export function createClient() {
+ return createBrowserClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
+ )
+}
diff --git a/utils/supabase/middleware.ts b/utils/supabase/middleware.ts
new file mode 100644
index 0000000..3ca08e1
--- /dev/null
+++ b/utils/supabase/middleware.ts
@@ -0,0 +1,60 @@
+import { createServerClient } from '@supabase/ssr'
+import { NextResponse, type NextRequest } from 'next/server'
+
+export async function updateSession(request: NextRequest) {
+ let supabaseResponse = NextResponse.next({
+ request,
+ })
+
+ const supabase = createServerClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ {
+ cookies: {
+ getAll() {
+ return request.cookies.getAll()
+ },
+ setAll(cookiesToSet) {
+ cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
+ supabaseResponse = NextResponse.next({
+ request,
+ })
+ cookiesToSet.forEach(({ name, value, options }) =>
+ supabaseResponse.cookies.set(name, value, options)
+ )
+ },
+ },
+ }
+ )
+
+ // IMPORTANT: Avoid writing any logic between createServerClient and
+ // getUser(). A simple mistake can make it very hard to debug
+ // issues with users being logged out.
+
+ const {
+ data: { user },
+ } = await supabase.auth.getUser()
+
+ if (
+ !user &&
+ !request.nextUrl.pathname.startsWith('/login') &&
+ !request.nextUrl.pathname.startsWith('/auth') &&
+ request.nextUrl.pathname.startsWith('/admin')
+ ) {
+ // no user, potentially respond by redirecting the user to the login page
+ const url = request.nextUrl.clone()
+ url.pathname = '/login'
+ return NextResponse.redirect(url)
+ }
+
+ // IMPORTANT: You *must* return the supabaseResponse object as is. If you're creating a
+ // new response object with NextResponse.next() make sure to:
+ // 1. Pass the request in it, like so:
+ // const myNewResponse = NextResponse.next({ request })
+ // 2. Copy over the cookies, like so:
+ // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())
+ // 3. Change the myNewResponse object to fit your needs, but make sure to return it!
+ // If you don't, you can accidentally upend the user's session.
+
+ return supabaseResponse
+}
diff --git a/utils/supabase/server.ts b/utils/supabase/server.ts
new file mode 100644
index 0000000..6e18132
--- /dev/null
+++ b/utils/supabase/server.ts
@@ -0,0 +1,29 @@
+import { createServerClient } from '@supabase/ssr'
+import { cookies } from 'next/headers'
+
+export async function createClient() {
+ const cookieStore = await cookies()
+
+ return createServerClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ {
+ cookies: {
+ getAll() {
+ return cookieStore.getAll()
+ },
+ setAll(cookiesToSet) {
+ try {
+ cookiesToSet.forEach(({ name, value, options }) =>
+ cookieStore.set(name, value, options)
+ )
+ } catch {
+ // The `setAll` method was called from a Server Component.
+ // This can be ignored if you have middleware refreshing
+ // user sessions.
+ }
+ },
+ },
+ }
+ )
+}