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

+
+
+ +
+
+ + {/* Main Metrics */} +
+ {metrics.map((item, i) => ( +
+

{item.label}

+
+

{item.value}

+
+ {item.positive ? : } + {item.trend} +
+
+
+ ))} +
+ + {/* Charts Grid */} +
+ {/* Performance Chart */} +
+
+

Ciro Trendi (12 Gün)

+
+
+
+ Gerçekleşen +
+
+
+ +
+ {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.

+ +
+ +
+
+
+
+ ); +} 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

+
+ +
+ + {/* Stats */} +
+
+
+ +
+
+

{customers.length.toLocaleString('tr-TR')}

+

Toplam Müşteri

+
+
+
+
+ +
+
+

Gerçek

+

Canlı Veri

+
+
+
+
+ +
+
+

{customers.filter(c => c.phone !== 'Telefon Yok').length}

+

Telefon Kayıtlı

+
+
+
+ + {/* List */} +
+
+
+ + +
+ +
+ +
+ + + + + + + + + + + + {customers.map((customer, i) => ( + + + + + + + + ))} + +
Müşteri BilgileriSegmentSiparişToplam HarcamaAksiyonlar
+
+
+ {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 */} + + + {/* Main Container */} +
+ {/* Top Bar */} +
+
+

Yönetim Paneli

+
+ + +
+
+ +
+
+ + +
+ +
+
+

Admin

+

Süper Admin

+
+
+
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 */} +
+
+

Toplam Ciro

+
+ +
+
+
+

{stats.totalRevenue.toLocaleString('tr-TR', { minimumFractionDigits: 2 })} ₺

+
+ + Sistem Aktif gerçek zamanlı veri +
+
+
+ + {/* Total Customers */} +
+
+

Toplam Müşteri

+
+ +
+
+
+

{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 */} +
+
+

Başarı Oranı

+
+ +
+
+
+

{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

+
+ +
+ +
+ {/* 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

+
+
+ +
+
+
+ Kart (60%) +
+
+
+ Havale (20%) +
+
+
+ Cüzdan (15%) +
+
+
+ Diğer (5%) +
+
+
+
+
+ + {/* Bottom Section: Recent Transactions Table */} +
+
+

Son İşlemler

+ + Tümünü Gör + +
+ +
+ + + + + + + + + + + + {recentTransactions.map((t) => ( + + + + + + + + ))} + +
İşlem IDMüşteri / RefTarihTutarDurum
+
+ #{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

+
+ +
+ +
+ {/* Left Column: Sections */} +
+ {/* General Section */} +
+
+
+ +
+

Genel Ayarlar

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* Security Section */} +
+
+
+ +
+

Güvenlik

+
+ +
+
+
+

İki Faktörlü Doğrulama

+

Hesabınıza ekstra bir güvenlik katmanı ekleyin

+
+
+
+
+
+ +
+
+

API Erişimi

+

Harici uygulamalar için anahtar yönetimi

+
+ +
+
+
+
+ + {/* Right Column: Notifications & Danger Zone */} +
+
+
+
+ +
+

Bildirimler

+
+
+ {['Yeni Satışlar', 'Müşteri Mesajları', 'Sistem Güncellemeleri'].map((item) => ( + + ))} +
+
+ +
+
+ +

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. +

+ +
+
+
+ + {/* 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 */} +
+
+
+ + +
+ +
+ + +
+ + {/* Full Transactions Table */} +
+
+ + + + + + + + + + + + + {transactions.map((t) => ( + + + + + + + + + ))} + +
İşlem IDReferans / KaynakTarih & SaatTutarDurumİşlemler
+ #{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}

+ +
+ ); + } + + return ( +
+ {/* Header */} + + + {/* Main Content */} +
+ {/* Left Column: Product Info */} +
+
+
+ Digital NFT Product +
+ 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 ? ( + + ) : ( + + + + )} + +
+ + + Mağazaya Dön + +
+
+ )} +
+
+ + {/* Footer */} +
+

+ © 2026 froydPay Inc. Tüm hakları saklıdır. +

+
+
+ ); +} + +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. +

+ +
+ +

Yönlendiriliyor...

+ + {callbackUrl && ( + + Yönlendirme olmazsa buraya tıklayın + + )} +
+
+ ); +} + +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

+
+
+ +
+
+ +
+ + setEmail(e.target.value)} + placeholder="admin@froyd.io" + className="w-full pl-14 pr-6 py-4 bg-gray-50 border-none rounded-2xl text-sm font-medium focus:ring-2 focus:ring-blue-500 outline-none" + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + className="w-full pl-14 pr-6 py-4 bg-gray-50 border-none rounded-2xl text-sm font-medium focus:ring-2 focus:ring-blue-500 outline-none" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + + +
+ +

+ 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 ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

+
+ + + {/* Features */} +
+
+
+
+ +
+

Güvenli Altyapı

+

+ Stripe Elements kullanarak kart bilgilerini asla sunucularınızda saklamazsınız. Tam güvenlik garantisi. +

+
+
+
+ +
+

Dinamik Ödeme

+

+ Herhangi bir URL parametresi ile ödeme başlatın. Projelerinize entegre etmek sadece bir dakika sürer. +

+
+
+
+ +
+

Merkezi Takip

+

+ Tüm projelerinizden gelen ödemeleri tek bir admin panelinden, anlık grafikler ve raporlarla izleyin. +

+
+
+
+ + {/* Footer */} +
+

© 2026 froyd Payment Platforms. Tüm hakları saklıdır.

+
); } 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 ( +
+
+

TOPLAM TUTAR

+

+ {amount.toLocaleString('tr-TR', { minimumFractionDigits: 2 })} {currency.toUpperCase() === 'TRY' || currency.toUpperCase() === 'TL' ? '₺' : currency.toUpperCase()} +

+

+ Güvenli ve Şifreli İşlem +

+
+ +
+ {/* Customer Details */} +
+
+ + setName(e.target.value)} + className="w-full p-4 bg-gray-50 border border-gray-200 rounded-2xl text-gray-900 focus:ring-2 focus:ring-blue-500 outline-none text-sm font-bold" + required + /> +
+
+ + setPhone(e.target.value)} + className="w-full p-4 bg-gray-50 border border-gray-200 rounded-2xl text-gray-900 focus:ring-2 focus:ring-blue-500 outline-none text-sm font-bold" + required + /> +
+
+ + + + {message && ( +
+ {message} +
+ )} + + +
+ +
+
+
+

256-BIT SSL ŞİFRELİ İŞLEM

+
+ +
+
+ Stripe GÜVENCESİYLE +
+
+ PCI DSS UYUMLU +
+
+
+
+ ); +} 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 */} +
+
+ + setName(e.target.value)} + className="w-full p-4 bg-gray-50 border border-gray-200 rounded-2xl text-gray-900 focus:ring-2 focus:ring-blue-500 outline-none text-sm font-bold" + /> +
+
+ + setPhone(e.target.value)} + className="w-full p-4 bg-gray-50 border border-gray-200 rounded-2xl text-gray-900 focus:ring-2 focus:ring-blue-500 outline-none text-sm font-bold" + /> +
+
+ +
+ +
+ +
+ VISA +
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ + {/* Failed scenario trigger for testing */} + {process.env.NEXT_PUBLIC_USE_MOCK_PAYMENTS === 'true' && !isLoading && ( + + )} +
+ +
+
+
+

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. + } + }, + }, + } + ) +}