Compare commits

26 Commits
kenan ... main

Author SHA1 Message Date
215788f858 hata düzeltme 2 2026-01-30 00:24:40 +03:00
d14b293bd8 hata düzeltme 2026-01-30 00:18:43 +03:00
d320787df6 Sms Rehber eklemesi,Mobil uyumluluk ayarları,iletişim sayfası düzenlemeler vb. 2026-01-30 00:09:16 +03:00
5fdb7592e7 hatadüzeltme 2 2026-01-29 22:09:37 +03:00
aec7396030 hata düzeltme 2026-01-29 21:59:49 +03:00
56cae7a56f hata düzeltme 2026-01-29 17:18:07 +03:00
54113726f4 güncelleme 2026-01-29 16:49:51 +03:00
10bfa3089e hata düzeltme 2026-01-28 00:26:18 +03:00
fde1c84ecb user eklleme 2026-01-27 23:40:10 +03:00
efddf97c01 hata düzektme 2026-01-26 00:28:35 +03:00
5c34df0f09 sms entegrasyonu ve ana sayfa işlemleri 2026-01-26 00:19:09 +03:00
1e1baa84ff hata düzeltme 2 2026-01-25 02:28:03 +03:00
a7ef9bd899 hata düzeltme 2026-01-25 02:13:46 +03:00
6992891ae3 Site içerik yönetimi 2026-01-25 02:03:27 +03:00
0fe49b5c96 web sitesi yönetimi 2026-01-25 01:46:12 +03:00
6e56b1e75f proje kontrol ve hata düzeltme 2026-01-24 18:56:29 +03:00
759bf2fbdd hata düzeltme 3 2026-01-13 23:51:30 +03:00
70f61a76b0 İmage upload,sıkıştırma 2026-01-13 23:36:40 +03:00
6bbae0de21 hata düzeltme 2 2026-01-13 22:57:39 +03:00
8eae770349 hata düzeltme 2 2026-01-13 22:47:07 +03:00
dc1b6f1359 hata düzeltme 1 2026-01-13 22:37:50 +03:00
32009b4886 hata ayıklama ve kod temizliği 2026-01-11 23:58:09 +03:00
b2a915240f Site yönlendirme 2026-01-10 21:39:12 +03:00
e80c8fd74b Profil hatası düzeltme 2026-01-10 21:33:45 +03:00
9c9e1ed9de Profil Hatası düzeltme 2026-01-10 21:04:58 +03:00
a41da2286f profi sayfası 2026-01-10 20:38:06 +03:00
118 changed files with 5622 additions and 903 deletions

6
.eslintrc.json Normal file
View File

@@ -0,0 +1,6 @@
{
"extends": [
"next/core-web-vitals",
"next/typescript"
]
}

View File

@@ -17,7 +17,16 @@
--- ---
## Mevcut Durum (Tamamlananlar) ## Mevcut Durum (Tamamlananlar)
- [x] Kullanıcı Yönetimi (Admin Ekle/Sil). - [x] Kullanıcı Yönetimi (Admin Ekle/Sil/Düzenle + Telefon).
- [x] Temel Site Ayarları (Başlık, İletişim). - [x] Temel Site Ayarları (Başlık, İletişim).
- [x] Ürün Yönetimi (Temel CRUD). - [x] Ürün Yönetimi (Temel CRUD).
- [x] Kategori Yönetimi (Arayüz hazır, veritabanı bekleniyor). - [x] Kategori Yönetimi (Arayüz hazır, veritabanı bekleniyor).
## 3. Tamamlanan Ek Özellikler (28.01.2026)
- [x] **NetGSM Entegrasyonu:** Login için SMS doğrulama (2FA) eklendi.
- [x] **Oto-Çıkış:** 15dk hareketsizlikte otomatik çıkış.
- [x] **Ürün Geliştirmeleri:**
- Aktif/Pasif durumu.
- Çoklu resim yükleme.
- Resim optimizasyonu.
-

View File

@@ -0,0 +1,45 @@
'use server'
import { createClient } from "@/lib/supabase-server"
import { SiteContent } from "@/types/cms"
import { revalidatePath } from "next/cache"
export async function updateSiteContent(contents: SiteContent[]) {
const supabase = await createClient()
try {
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { success: false, error: "Oturum açmanız gerekiyor" }
}
// Upsert each content item
// Since we might have many items, we can do this in parallel or a single upsert if the structure allows
// Supabase upsert accepts an array
const { error } = await supabase
.from('site_contents')
.upsert(
contents.map(item => ({
key: item.key,
value: item.value,
type: item.type,
section: item.section,
updated_at: new Date().toISOString()
}))
)
if (error) {
console.error('CMS Update Error:', error)
return { success: false, error: "Güncelleme sırasında bir hata oluştu: " + error.message }
}
revalidatePath('/dashboard/cms/content')
revalidatePath('/') // Revalidate home page as it likely uses these settings
return { success: true }
} catch (error) {
console.error('CMS Update Error:', error)
return { success: false, error: "Bir hata oluştu" }
}
}

View File

@@ -0,0 +1,61 @@
import { createClient } from "@/lib/supabase-server"
import { ContentForm } from "@/components/dashboard/content-form"
import { SiteContent } from "@/types/cms"
export default async function ContentPage() {
const supabase = await createClient()
const { data: contents } = await supabase
.from('site_contents')
.select('*')
.order('key')
// Define default contents that should exist
const DEFAULT_CONTENTS: SiteContent[] = [
// General
{ key: 'site_title', value: 'ParaKasa', type: 'text', section: 'general' },
{ key: 'site_description', value: '', type: 'long_text', section: 'general' },
{ key: 'site_logo', value: '', type: 'image_url', section: 'general' },
// Contact
{ key: 'contact_phone', value: '', type: 'text', section: 'contact' },
{ key: 'contact_email', value: '', type: 'text', section: 'contact' },
{ key: 'contact_address', value: '', type: 'long_text', section: 'contact' },
{ key: 'social_instagram', value: '', type: 'text', section: 'contact' },
{ key: 'social_youtube', value: '', type: 'text', section: 'contact' },
{ key: 'social_tiktok', value: '', type: 'text', section: 'contact' },
{ key: 'contact_map_embed', value: '', type: 'html', section: 'contact' },
]
// Merge default contents with existing contents
const mergedContents = [...(contents as SiteContent[] || [])]
const existingKeys = new Set(mergedContents.map(c => c.key))
DEFAULT_CONTENTS.forEach(item => {
if (!existingKeys.has(item.key)) {
mergedContents.push(item)
}
})
return (
<div className="flex-1 space-y-4 p-8 pt-6">
<div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">İçerik Yönetimi</h2>
</div>
<div className="hidden h-full flex-1 flex-col space-y-8 md:flex">
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-1">
<div className="col-span-1">
<div className="space-y-6">
<div>
<p className="text-sm text-muted-foreground">
Site başlığı, sloganlar, iletişim bilgileri ve logoları buradan yönetebilirsiniz.
</p>
</div>
<ContentForm initialContent={mergedContents} />
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,76 @@
import { getCustomers, deleteCustomer } from "@/lib/customers/actions"
import { CustomerForm } from "@/components/dashboard/customer-form"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Trash, Edit } from "lucide-react"
export default async function CustomersPage() {
const { data: customers } = await getCustomers()
return (
<div className="flex-1 space-y-4 p-8 pt-6">
<div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Müşteriler</h2>
<div className="flex items-center space-x-2">
<CustomerForm />
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Ad Soyad</TableHead>
<TableHead>E-Posta</TableHead>
<TableHead>Telefon</TableHead>
<TableHead>Adres</TableHead>
<TableHead className="w-[100px]">İşlemler</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{customers?.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center">
Henüz müşteri bulunmuyor.
</TableCell>
</TableRow>
)}
{customers?.map((customer) => (
<TableRow key={customer.id}>
<TableCell className="font-medium">{customer.full_name}</TableCell>
<TableCell>{customer.email || "-"}</TableCell>
<TableCell>{customer.phone || "-"}</TableCell>
<TableCell className="truncate max-w-[200px]">{customer.address || "-"}</TableCell>
<TableCell className="flex items-center gap-2">
<CustomerForm
customer={customer}
trigger={
<Button variant="ghost" size="icon" className="h-8 w-8">
<Edit className="h-4 w-4" />
</Button>
}
/>
<form action={async () => {
'use server'
await deleteCustomer(customer.id)
}}>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive">
<Trash className="h-4 w-4" />
</Button>
</form>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { createClient } from "@/lib/supabase-server" import { createClient } from "@/lib/supabase-server"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { DollarSign, ShoppingCart, Users, CreditCard, Package } from "lucide-react" import { DollarSign, ShoppingCart, Users, Package } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -129,7 +129,7 @@ export default async function DashboardPage() {
) )
} }
function PlusIcon(props: any) { function PlusIcon(props: React.SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg
{...props} {...props}

View File

@@ -3,49 +3,97 @@
import { createClient } from "@/lib/supabase-server" import { createClient } from "@/lib/supabase-server"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
export async function createProduct(data: any) { interface ProductData {
name: string
category: string
description?: string
price: number
image_url?: string
is_active?: boolean
images?: string[]
product_code?: string
}
export async function createProduct(data: ProductData) {
const supabase = createClient() const supabase = createClient()
// Validate data manually or use Zod schema here again securely
// For simplicity, we assume data is coming from the strongly typed Client Form
// In production, ALWAYS validate server-side strictly.
try { try {
const { error } = await supabase.from("products").insert({ // 1. Create Product
const { data: product, error } = await supabase.from("products").insert({
name: data.name, name: data.name,
category: data.category, category: data.category,
description: data.description, description: data.description,
price: data.price, price: data.price,
image_url: data.image_url, image_url: data.image_url, // Main image (can be first of images)
}) is_active: data.is_active ?? true,
product_code: data.product_code
}).select().single()
if (error) throw error if (error) throw error
revalidatePath("/dashboard/products") // 2. Insert Images (if any)
return { success: true } if (data.images && data.images.length > 0) {
} catch (error: any) { const imageInserts = data.images.map((url, index) => ({
return { success: false, error: error.message } product_id: product.id,
image_url: url,
display_order: index
}))
const { error: imgError } = await supabase.from("product_images").insert(imageInserts)
if (imgError) {
console.error("Error inserting images:", imgError)
// We don't throw here to avoid failing the whole product creation if just images fail
} }
} }
export async function updateProduct(id: number, data: any) { revalidatePath("/dashboard/products")
return { success: true }
} catch (error) {
return { success: false, error: (error as Error).message }
}
}
export async function updateProduct(id: number, data: ProductData) {
const supabase = createClient() const supabase = createClient()
try { try {
// 1. Update Product
const { error } = await supabase.from("products").update({ const { error } = await supabase.from("products").update({
name: data.name, name: data.name,
category: data.category, category: data.category,
description: data.description, description: data.description,
price: data.price, price: data.price,
image_url: data.image_url, image_url: data.image_url,
is_active: data.is_active,
product_code: data.product_code
}).eq("id", id) }).eq("id", id)
if (error) throw error if (error) throw error
// 2. Update Images
// Strategy: Delete all and re-insert is simplest for now.
// Or better: Differential update. For simplicity in MVP: Delete all for this product and re-insert *if* new images provided.
// Actually, if we want to keep existing ones, we need more complex logic.
// For now, let's assume the form sends the FULL list of current images.
if (data.images) {
// Delete old
await supabase.from("product_images").delete().eq("product_id", id)
// Insert new
if (data.images.length > 0) {
const imageInserts = data.images.map((url, index) => ({
product_id: id,
image_url: url,
display_order: index
}))
await supabase.from("product_images").insert(imageInserts)
}
}
revalidatePath("/dashboard/products") revalidatePath("/dashboard/products")
revalidatePath(`/dashboard/products/${id}`) revalidatePath(`/dashboard/products/${id}`)
return { success: true } return { success: true }
} catch (error: any) { } catch (error) {
return { success: false, error: error.message } return { success: false, error: (error as Error).message }
} }
} }

View File

@@ -32,7 +32,7 @@ export default async function ProductsPage() {
</div> </div>
</div> </div>
<div className="border rounded-md"> <div className="border rounded-md overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>

View File

@@ -1,14 +1,48 @@
import { createClient } from "@/lib/supabase-server" import { createClient } from "@/lib/supabase-server"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button" import { UserForm } from "@/components/dashboard/user-form"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { getProfile } from "@/lib/data"
export default async function ProfilePage() { export default async function ProfilePage() {
const supabase = createClient() const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser() const { data: { user } } = await supabase.auth.getUser()
if (!user) {
// Should be protected by middleware but just in case
return <div>Lütfen giriş yapın.</div>
}
// Fetch profile data
const profile = await getProfile(user.id)
if (!profile) {
// Fallback for user without profile row?
// Or create one on the fly?
return <div>Profil verisi bulunamadı.</div>
}
// Improved name parsing logic
const fullName = (profile.full_name || "").trim()
const firstSpaceIndex = fullName.indexOf(' ')
let firstName = fullName
let lastName = ""
if (firstSpaceIndex > 0) {
firstName = fullName.substring(0, firstSpaceIndex)
lastName = fullName.substring(firstSpaceIndex + 1)
}
const initialData = {
firstName,
lastName,
phone: profile.phone || "",
email: user.email || "",
role: profile.role || "user"
}
return ( return (
<div className="flex-1 space-y-4 p-8 pt-6"> <div className="flex-1 space-y-4 p-8 pt-6">
<div className="flex items-center justify-between space-y-2"> <div className="flex items-center justify-between space-y-2">
@@ -20,28 +54,18 @@ export default async function ProfilePage() {
<CardHeader> <CardHeader>
<CardTitle>Genel Bilgiler</CardTitle> <CardTitle>Genel Bilgiler</CardTitle>
<CardDescription> <CardDescription>
Kişisel profil bilgileriniz. Kişisel profil bilgilerinizi buradan güncelleyebilirsiniz.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4 mb-6">
<Avatar className="h-20 w-20"> <Avatar className="h-20 w-20">
<AvatarImage src="/avatars/01.png" alt="@parakasa" /> <AvatarImage src="/avatars/01.png" alt="@parakasa" />
<AvatarFallback>PK</AvatarFallback> <AvatarFallback>PK</AvatarFallback>
</Avatar> </Avatar>
<Button variant="outline">Fotoğraf Değiştir</Button>
</div> </div>
<div className="space-y-1"> <UserForm initialData={initialData} mode="profile" />
<Label htmlFor="email">E-posta</Label>
<Input id="email" value={user?.email || ""} disabled />
<p className="text-xs text-muted-foreground">E-posta adresi değiştirilemez.</p>
</div>
<div className="space-y-1">
<Label htmlFor="role">Rol</Label>
<Input id="role" value={user?.role === 'authenticated' ? 'Yönetici' : 'Kullanıcı'} disabled />
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -0,0 +1,15 @@
import { PasswordForm } from "../../../../../components/dashboard/password-form"
export default function ChangePasswordPage() {
return (
<div className="flex-1 space-y-4 p-8 pt-6">
<div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Şifre Değiştir</h2>
</div>
<div className="max-w-md">
<PasswordForm />
</div>
</div>
)
}

View File

@@ -1,42 +0,0 @@
"use server"
import { createClient } from "@/lib/supabase-server"
import { revalidatePath } from "next/cache"
export async function updateSiteSettings(data: {
site_title: string
site_description: string
contact_email: string
contact_phone: string
currency: string
}) {
const supabase = createClient()
// Check admin is already handled by RLS on database level, but we can double check here
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: "Oturum açmanız gerekiyor." }
// We update the single row where id is likely 1 or just the first row
// Since we initialized it with one row, we can just update match on something true or fetch id first.
// Easier: Update all rows (there should only be one) or fetch the specific ID first.
// Let's first get the ID just to be precise
const { data: existing } = await supabase.from('site_settings').select('id').single()
if (!existing) {
return { error: "Ayarlar bulunamadı." }
}
const { error } = await supabase
.from('site_settings')
.update(data)
.eq('id', existing.id)
if (error) {
return { error: "Ayarlar güncellenemedi: " + error.message }
}
revalidatePath("/dashboard/settings")
revalidatePath("/") // Revalidate home as it might use these settings
return { success: true }
}

View File

@@ -1,45 +1,79 @@
import { createClient } from "@/lib/supabase-server" import { createClient } from "@/lib/supabase-server"
import { SiteSettingsForm } from "@/components/dashboard/site-settings-form" import { SettingsTabs } from "@/components/dashboard/settings-tabs"
import { AppearanceForm } from "@/components/dashboard/appearance-form" import { getSmsSettings } from "@/lib/sms/actions"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { SiteContent } from "@/types/cms"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Button } from "@/components/ui/button"
export default async function SettingsPage() { export default async function SettingsPage() {
const supabase = createClient() const supabase = createClient()
// Fetch site settings // Fetch SMS settings
const { data: settings } = await supabase const smsResponse = await getSmsSettings()
.from('site_settings') const smsSettings = smsResponse.data || null
// Fetch Users (Profiles)
const { data: profiles } = await supabase
.from("profiles")
.select("*")
.order("created_at", { ascending: false })
// Fetch Site Contents (CMS)
const { data: contents } = await supabase
.from('site_contents')
.select('*') .select('*')
.single() .order('key')
// Define default contents for CMS
const DEFAULT_CONTENTS: SiteContent[] = [
// General
{ key: 'site_title', value: 'ParaKasa', type: 'text', section: 'general' },
{ key: 'site_description', value: '', type: 'long_text', section: 'general' },
{ key: 'site_logo', value: '', type: 'image_url', section: 'general' },
{ key: 'favicon_url', value: '', type: 'image_url', section: 'general' },
// Home
{ key: 'home_hero_title', value: 'GÜVENLİK SINIR <br /> <span class="text-transparent bg-clip-text bg-gradient-to-r from-slate-200 to-slate-500">TANIMAZ</span>', type: 'html', section: 'home' },
{ key: 'home_hero_description', value: 'En değerli varlıklarınız için tasarlanmış yüksek güvenlikli çelik kasalar. Modern teknoloji ve zanaatkarlığın mükemmel uyumu.', type: 'long_text', section: 'home' },
{ key: 'home_hero_button_text', value: 'Koleksiyonu İncele', type: 'text', section: 'home' },
{ key: 'home_hero_bg_image', value: '/images/hero-safe.png', type: 'image_url', section: 'home' },
{ key: 'home_categories_title', value: 'Ürün Kategorileri', type: 'text', section: 'home' },
{ key: 'home_categories_description', value: 'İhtiyacınıza uygun güvenlik çözümünü seçin.', type: 'text', section: 'home' },
// Contact
{ key: 'contact_phone', value: '', type: 'text', section: 'contact' },
{ key: 'contact_email', value: '', type: 'text', section: 'contact' },
{ key: 'contact_address', value: '', type: 'long_text', section: 'contact' },
{ key: 'social_instagram', value: '', type: 'text', section: 'contact' },
{ key: 'social_youtube', value: '', type: 'text', section: 'contact' },
{ key: 'social_tiktok', value: '', type: 'text', section: 'contact' },
{ key: 'contact_map_embed', value: '', type: 'html', section: 'contact' },
// SEO
{ key: 'meta_keywords', value: '', type: 'text', section: 'seo' },
{ key: 'meta_author', value: '', type: 'text', section: 'seo' },
// Scripts & Analytics
{ key: 'google_analytics_id', value: '', type: 'text', section: 'scripts' },
{ key: 'facebook_pixel_id', value: '', type: 'text', section: 'scripts' },
]
// Merge default contents with existing contents
const mergedContents = [...(contents as SiteContent[] || [])]
const existingKeys = new Set(mergedContents.map(c => c.key))
DEFAULT_CONTENTS.forEach(item => {
if (!existingKeys.has(item.key)) {
mergedContents.push(item)
}
})
return ( return (
<div className="flex-1 space-y-4 p-8 pt-6"> <div className="flex-1 space-y-4 p-8 pt-6">
<h2 className="text-3xl font-bold tracking-tight">Ayarlar</h2> <h2 className="text-3xl font-bold tracking-tight">Ayarlar</h2>
<SettingsTabs
{/* Site General Settings */} smsSettings={smsSettings}
<div className="grid gap-4"> users={profiles || []}
<SiteSettingsForm initialData={settings} /> contents={mergedContents}
</div> />
<div className="grid gap-4 grid-cols-1 md:grid-cols-2">
<AppearanceForm />
<Card>
<CardHeader>
<CardTitle>Hesap Güvenliği</CardTitle>
<CardDescription>
Şifre ve oturum yönetimi.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button variant="outline" className="w-full">Şifre Değiştir</Button>
<Button variant="destructive" className="w-full">Hesabı Sil</Button>
</CardContent>
</Card>
</div>
</div> </div>
) )
} }

View File

@@ -0,0 +1,34 @@
import { SliderForm } from "@/components/dashboard/slider-form"
import { createClient } from "@/lib/supabase-server"
import { notFound } from "next/navigation"
interface EditSliderPageProps {
params: {
id: string
}
}
export default async function EditSliderPage({ params }: EditSliderPageProps) {
const supabase = createClient()
const { data: slider } = await supabase
.from('sliders')
.select('*')
.eq('id', params.id)
.single()
if (!slider) {
notFound()
}
return (
<div className="flex-1 space-y-4 p-8 pt-6">
<div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Slider Düzenle</h2>
</div>
<div className="max-w-2xl">
<SliderForm initialData={slider} />
</div>
</div>
)
}

View File

@@ -0,0 +1,124 @@
"use server"
import { createClient } from "@/lib/supabase-server"
import { createClient as createSupabaseClient } from "@supabase/supabase-js"
import { revalidatePath } from "next/cache"
// Admin client for privileged operations
const supabaseAdmin = createSupabaseClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
autoRefreshToken: false,
persistSession: false
}
}
)
async function assertAdmin() {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error("Oturum açmanız gerekiyor.")
const { data: profile } = await supabase.from('profiles').select('role').eq('id', user.id).single()
if (profile?.role !== 'admin') throw new Error("Yetkisiz işlem.")
return user
}
export async function getSliders() {
const supabase = createClient()
// Everyone can read, so normal client is fine
const { data, error } = await supabase
.from('sliders')
.select('*')
.order('order', { ascending: true })
.order('created_at', { ascending: false })
if (error) return { error: error.message }
return { data }
}
export async function createSlider(data: {
title: string
description?: string
image_url: string
link?: string
order?: number
is_active?: boolean
}) {
try {
await assertAdmin()
const { error } = await supabaseAdmin.from('sliders').insert({
title: data.title,
description: data.description,
image_url: data.image_url,
link: data.link,
order: data.order || 0,
is_active: data.is_active ?? true
})
if (error) throw error
revalidatePath("/dashboard/sliders")
revalidatePath("/") // Homepage cache update
return { success: true }
} catch (error) {
return { error: (error as Error).message }
}
}
export async function updateSlider(id: string, data: {
title: string
description?: string
image_url: string
link?: string
order?: number
is_active?: boolean
}) {
try {
await assertAdmin()
const { error } = await supabaseAdmin
.from('sliders')
.update({
title: data.title,
description: data.description,
image_url: data.image_url,
link: data.link,
order: data.order,
is_active: data.is_active,
// updated_at trigger usually handles time, but we don't have it in schema yet, so maybe add later
})
.eq('id', id)
if (error) throw error
revalidatePath("/dashboard/sliders")
revalidatePath("/")
return { success: true }
} catch (error) {
return { error: (error as Error).message }
}
}
export async function deleteSlider(id: string) {
try {
await assertAdmin()
const { error } = await supabaseAdmin
.from('sliders')
.delete()
.eq('id', id)
if (error) throw error
revalidatePath("/dashboard/sliders")
revalidatePath("/")
return { success: true }
} catch (error) {
return { error: (error as Error).message }
}
}

View File

@@ -0,0 +1,14 @@
import { SliderForm } from "@/components/dashboard/slider-form"
export default function NewSliderPage() {
return (
<div className="flex-1 space-y-4 p-8 pt-6">
<div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Yeni Slider Oluştur</h2>
</div>
<div className="max-w-2xl">
<SliderForm />
</div>
</div>
)
}

View File

@@ -0,0 +1,85 @@
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Plus, Pencil, GripVertical } from "lucide-react"
import { getSliders } from "./actions"
import { Card, CardContent } from "@/components/ui/card"
import Image from "next/image"
import { Badge } from "@/components/ui/badge"
export default async function SlidersPage() {
const { data: sliders, error } = await getSliders()
if (error) {
return <div className="p-8 text-red-500">Hata: {error}</div>
}
return (
<div className="flex-1 space-y-4 p-8 pt-6">
<div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Slider Yönetimi</h2>
<div className="flex items-center space-x-2">
<Link href="/dashboard/sliders/new">
<Button>
<Plus className="mr-2 h-4 w-4" /> Yeni Slider Ekle
</Button>
</Link>
</div>
</div>
<div className="grid gap-4">
{sliders?.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center p-12 text-muted-foreground">
<p>Henüz hiç slider eklenmemiş.</p>
</CardContent>
</Card>
) : (
sliders?.map((slider) => (
<Card key={slider.id} className="overflow-hidden">
<div className="flex flex-col sm:flex-row items-center p-2 gap-4">
<div className="p-2 cursor-move text-muted-foreground">
<GripVertical className="h-5 w-5" />
</div>
<div className="relative w-full sm:w-48 h-32 sm:h-24 rounded-md overflow-hidden bg-slate-100 flex-shrink-0">
<Image
src={slider.image_url}
alt={slider.title}
fill
className="object-cover"
/>
</div>
<div className="flex-1 min-w-0 grid gap-1 text-center sm:text-left">
<div className="flex items-center gap-2 justify-center sm:justify-start">
<h3 className="font-semibold truncate">{slider.title}</h3>
{!slider.is_active && (
<Badge variant="secondary">Pasif</Badge>
)}
<Badge variant="outline">Sıra: {slider.order}</Badge>
</div>
<p className="text-sm text-muted-foreground truncate">
{slider.description || "Açıklama yok"}
</p>
</div>
<div className="flex items-center gap-2 p-2">
<Link href={`/dashboard/sliders/${slider.id}`}>
<Button variant="ghost" size="icon">
<Pencil className="h-4 w-4" />
</Button>
</Link>
{/* Delete functionality usually needs a client component or form action,
for simplicity here we will just link to edit,
or we can add a delete button with server action in a separate client component if needed.
Ideally, list items should be client components to handle delete easily.
*/}
</div>
</div>
</Card>
))
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,65 @@
import { getSmsLogs } from "@/lib/sms/actions"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { format } from "date-fns"
import { tr } from "date-fns/locale"
export default async function SmsLogsPage() {
const { data: logs, error } = await getSmsLogs(100)
if (error) {
return <div className="p-8">Hata: {error}</div>
}
return (
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">SMS Geçmişi</h2>
<p className="text-muted-foreground">Son gönderilen mesajların durumu.</p>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]">Tarih</TableHead>
<TableHead>Numara</TableHead>
<TableHead>Mesaj</TableHead>
<TableHead>Durum</TableHead>
<TableHead className="text-right">Kod</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs?.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center">
Kayıt bulunamadı.
</TableCell>
</TableRow>
)}
{logs?.map((log) => (
<TableRow key={log.id}>
<TableCell className="font-medium">
{log.created_at ? format(new Date(log.created_at), "dd MMM yyyy HH:mm", { locale: tr }) : "-"}
</TableCell>
<TableCell>{log.phone}</TableCell>
<TableCell className="max-w-[300px] truncate" title={log.message}>{log.message}</TableCell>
<TableCell>
<Badge variant={log.status === 'success' ? 'default' : 'destructive'}>
{log.status === 'success' ? 'Başarılı' : 'Hatalı'}
</Badge>
</TableCell>
<TableCell className="text-right font-mono text-xs">{log.response_code}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}

View File

@@ -0,0 +1,9 @@
import { getCustomers } from "@/lib/customers/actions"
import SmsPageClient from "@/components/dashboard/sms-page-client"
export default async function SmsPage() {
// Fetch all customers to show in the list
const { data: customers } = await getCustomers()
return <SmsPageClient customers={customers || []} />
}

View File

@@ -64,6 +64,7 @@ async function getUserDetails(userId: string) {
firstName, firstName,
lastName, lastName,
email: user.email || "", email: user.email || "",
role: profile.role as "admin" | "user" role: profile.role as "admin" | "user",
phone: profile.phone || ""
} }
} }

View File

@@ -3,7 +3,6 @@
import { createClient } from "@/lib/supabase-server" import { createClient } from "@/lib/supabase-server"
import { createClient as createSupabaseClient } from "@supabase/supabase-js" import { createClient as createSupabaseClient } from "@supabase/supabase-js"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
// WARNING: specialized client for admin actions only // WARNING: specialized client for admin actions only
// This requires SUPABASE_SERVICE_ROLE_KEY to be set in .env.local // This requires SUPABASE_SERVICE_ROLE_KEY to be set in .env.local
@@ -18,21 +17,13 @@ const supabaseAdmin = createSupabaseClient(
} }
) )
export async function createUser(firstName: string, lastName: string, email: string, password: string, role: 'admin' | 'user') { export async function createUser(firstName: string, lastName: string, email: string, password: string, role: 'admin' | 'user', phone?: string) {
const supabase = createClient()
// 1. Check if current user is admin // 1. Check if current user is admin
const { data: { user: currentUser } } = await supabase.auth.getUser() try {
if (!currentUser) return { error: "Oturum açmanız gerekiyor." } await assertAdmin()
} catch (error) {
const { data: profile } = await supabase return { error: (error as Error).message }
.from('profiles')
.select('role')
.eq('id', currentUser.id)
.single()
if (!profile || profile.role !== 'admin') {
return { error: "Yetkisiz işlem. Sadece yöneticiler kullanıcı oluşturabilir." }
} }
// 2. Create user using Admin client // 2. Create user using Admin client
@@ -59,7 +50,8 @@ export async function createUser(firstName: string, lastName: string, email: str
.insert({ .insert({
id: newUser.user.id, id: newUser.user.id,
full_name: `${firstName} ${lastName}`.trim(), full_name: `${firstName} ${lastName}`.trim(),
role: role role: role,
phone: phone
}) })
if (profileError) { if (profileError) {
@@ -73,14 +65,13 @@ export async function createUser(firstName: string, lastName: string, email: str
} }
export async function deleteUser(userId: string) { export async function deleteUser(userId: string) {
const supabase = createClient()
// Check admin // Check admin
const { data: { user: currentUser } } = await supabase.auth.getUser() try {
if (!currentUser) return { error: "Oturum açmanız gerekiyor." } await assertAdmin()
} catch (error) {
const { data: profile } = await supabase.from('profiles').select('role').eq('id', currentUser.id).single() return { error: (error as Error).message }
if (profile?.role !== 'admin') return { error: "Yetkisiz işlem." } }
// Delete user // Delete user
const { error } = await supabaseAdmin.auth.admin.deleteUser(userId) const { error } = await supabaseAdmin.auth.admin.deleteUser(userId)
@@ -91,30 +82,29 @@ export async function deleteUser(userId: string) {
return { success: true } return { success: true }
} }
export async function updateUser(userId: string, data: { firstName: string, lastName: string, email: string, password?: string, role: 'admin' | 'user' }) { export async function updateUser(userId: string, data: { firstName: string, lastName: string, email: string, password?: string, role: 'admin' | 'user', phone?: string }) {
const supabase = createClient()
// Check admin // Check admin
const { data: { user: currentUser } } = await supabase.auth.getUser() try {
if (!currentUser) return { error: "Oturum açmanız gerekiyor." } await assertAdmin()
} catch (error) {
// Check if current user is admin return { error: (error as Error).message }
const { data: profile } = await supabase.from('profiles').select('role').eq('id', currentUser.id).single() }
if (profile?.role !== 'admin') return { error: "Yetkisiz işlem." }
// 1. Update Profile (Role and Name) // 1. Update Profile (Role and Name)
const { error: profileError } = await supabaseAdmin const { error: profileError } = await supabaseAdmin
.from('profiles') .from('profiles')
.update({ .update({
full_name: `${data.firstName} ${data.lastName}`.trim(), full_name: `${data.firstName} ${data.lastName}`.trim(),
role: data.role role: data.role,
phone: data.phone
}) })
.eq('id', userId) .eq('id', userId)
if (profileError) return { error: "Profil güncellenemedi: " + profileError.message } if (profileError) return { error: "Profil güncellenemedi: " + profileError.message }
// 2. Update Auth (Email and Password) // 2. Update Auth (Email and Password)
const authUpdates: any = { const authUpdates: { email: string; user_metadata: { full_name: string }; password?: string } = {
email: data.email, email: data.email,
user_metadata: { user_metadata: {
full_name: `${data.firstName} ${data.lastName}`.trim() full_name: `${data.firstName} ${data.lastName}`.trim()
@@ -131,3 +121,50 @@ export async function updateUser(userId: string, data: { firstName: string, last
revalidatePath("/dashboard/users") revalidatePath("/dashboard/users")
return { success: true } return { success: true }
} }
export async function updateProfile(data: { firstName: string, lastName: string, phone?: string }) {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: "Oturum açmanız gerekiyor." }
const { error } = await supabase
.from('profiles')
.update({
full_name: `${data.firstName} ${data.lastName}`.trim(),
phone: data.phone
})
.eq('id', user.id)
if (error) return { error: "Profil güncellenemedi: " + error.message }
// Update Auth Metadata as well
if (data.firstName || data.lastName) {
await supabase.auth.updateUser({
data: {
full_name: `${data.firstName} ${data.lastName}`.trim()
}
})
}
revalidatePath("/dashboard/profile")
return { success: true }
}
async function assertAdmin() {
const supabase = createClient()
const { data: { user: currentUser } } = await supabase.auth.getUser()
if (!currentUser) throw new Error("Oturum açmanız gerekiyor.")
const { data: profile } = await supabase
.from('profiles')
.select('role')
.eq('id', currentUser.id)
.single()
if (!profile || profile.role !== 'admin') {
throw new Error("Yetkisiz işlem. Sadece yöneticiler bu işlemi gerçekleştirebilir.")
}
return currentUser
}

View File

@@ -1,8 +1,11 @@
import { getProfile } from "@/lib/data"
import { createClient } from "@/lib/supabase-server" import { createClient } from "@/lib/supabase-server"
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
import { Sidebar } from "@/components/dashboard/sidebar" import { Sidebar } from "@/components/dashboard/sidebar"
import { DashboardHeader } from "@/components/dashboard/header" import { DashboardHeader } from "@/components/dashboard/header"
import { AutoLogoutHandler } from "@/components/dashboard/auto-logout-handler"
export default async function DashboardLayout({ export default async function DashboardLayout({
children, children,
}: Readonly<{ }: Readonly<{
@@ -15,8 +18,11 @@ export default async function DashboardLayout({
redirect("/login") redirect("/login")
} }
const profile = await getProfile(user.id)
return ( return (
<div className="flex min-h-screen w-full flex-col bg-muted/40"> <div className="flex min-h-screen w-full flex-col bg-muted/40">
<AutoLogoutHandler />
<aside className="fixed inset-y-0 left-0 z-10 hidden w-64 flex-col border-r bg-background sm:flex"> <aside className="fixed inset-y-0 left-0 z-10 hidden w-64 flex-col border-r bg-background sm:flex">
<div className="flex h-14 items-center border-b px-4 lg:h-[60px] lg:px-6"> <div className="flex h-14 items-center border-b px-4 lg:h-[60px] lg:px-6">
<span className="font-semibold text-lg">ParaKasa Panel</span> <span className="font-semibold text-lg">ParaKasa Panel</span>
@@ -24,7 +30,7 @@ export default async function DashboardLayout({
<Sidebar className="flex-1" /> <Sidebar className="flex-1" />
</aside> </aside>
<div className="flex flex-col sm:gap-4 sm:py-4 sm:pl-64"> <div className="flex flex-col sm:gap-4 sm:py-4 sm:pl-64">
<DashboardHeader /> <DashboardHeader user={user} profile={profile} />
<main className="grid flex-1 items-start gap-4 p-4 sm:px-6 sm:py-0 md:gap-8"> <main className="grid flex-1 items-start gap-4 p-4 sm:px-6 sm:py-0 md:gap-8">
{children} {children}
</main> </main>

View File

@@ -0,0 +1,27 @@
import { Skeleton } from "@/components/ui/skeleton"
export default function DashboardLoading() {
return (
<div className="flex flex-col space-y-6 p-8">
<div className="flex items-center justify-between space-y-2">
<div className="space-y-2">
<Skeleton className="h-8 w-[200px]" />
<Skeleton className="h-4 w-[300px]" />
</div>
<div className="flex items-center space-x-2">
<Skeleton className="h-10 w-[120px]" />
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Skeleton className="h-[120px] rounded-xl" />
<Skeleton className="h-[120px] rounded-xl" />
<Skeleton className="h-[120px] rounded-xl" />
<Skeleton className="h-[120px] rounded-xl" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Skeleton className="col-span-4 h-[400px] rounded-xl" />
<Skeleton className="col-span-3 h-[400px] rounded-xl" />
</div>
</div>
)
}

View File

@@ -1,61 +1,22 @@
"use client" import { getSiteContents } from "@/lib/data"
import { ContactForm } from "@/components/contact/contact-form"
import { Mail, MapPin, Phone, Instagram, Youtube } from "lucide-react"
import { FaTiktok } from "react-icons/fa"
import Link from "next/link"
import { useState } from "react" export default async function ContactPage() {
import { useForm } from "react-hook-form" const siteSettings = await getSiteContents()
import { zodResolver } from "@hookform/resolvers/zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Card, CardContent } from "@/components/ui/card"
import { Mail, MapPin, Phone, Loader2, CheckCircle } from "lucide-react"
import { contactFormSchema, ContactFormValues } from "@/lib/schemas"
import { submitContactForm } from "@/lib/actions/contact"
import { toast } from "sonner"
export default function ContactPage() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const form = useForm<ContactFormValues>({
resolver: zodResolver(contactFormSchema),
defaultValues: {
name: "",
surname: "",
email: "",
phone: "",
subject: "",
message: "",
},
})
async function onSubmit(data: ContactFormValues) {
setIsSubmitting(true)
try {
const response = await submitContactForm(data)
if (response.success) {
setIsSuccess(true)
form.reset()
toast.success("Mesajınız başarıyla gönderildi.")
} else {
toast.error("Hata: " + response.error)
}
} catch (error) {
toast.error("Bir hata oluştu.")
} finally {
setIsSubmitting(false)
}
}
return ( return (
<div className="container py-12 md:py-24"> <div className="container py-12 md:py-24">
<div className="text-center mb-12"> <div className="text-center mb-8 md:mb-12">
<h1 className="text-4xl font-bold tracking-tight mb-4 font-outfit">İletişime Geçin</h1> <h1 className="text-3xl md:text-4xl font-bold tracking-tight mb-4 font-outfit">İletişime Geçin</h1>
<p className="text-muted-foreground max-w-xl mx-auto"> <p className="text-muted-foreground max-w-xl mx-auto">
Sorularınız, teklif talepleriniz veya teknik destek için bize ulaşın. Sorularınız, teklif talepleriniz veya teknik destek için bize ulaşın.
</p> </p>
</div> </div>
<div className="grid md:grid-cols-2 gap-12 max-w-5xl mx-auto"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 max-w-5xl mx-auto">
<div className="space-y-8"> <div className="space-y-8">
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-2xl font-semibold">İletişim Bilgileri</h2> <h2 className="text-2xl font-semibold">İletişim Bilgileri</h2>
@@ -63,9 +24,8 @@ export default function ContactPage() {
<MapPin className="w-6 h-6 text-primary mt-1" /> <MapPin className="w-6 h-6 text-primary mt-1" />
<div> <div>
<p className="font-medium">Merkez Ofis & Showroom</p> <p className="font-medium">Merkez Ofis & Showroom</p>
<p className="text-slate-600 dark:text-slate-400"> <p className="text-slate-600 dark:text-slate-400 whitespace-pre-wrap">
Organize Sanayi Bölgesi, 12. Cadde No: 45<br /> {siteSettings.contact_address || "Organize Sanayi Bölgesi, 12. Cadde No: 45\nBaşakşehir, İstanbul"}
Başakşehir, İstanbul
</p> </p>
</div> </div>
</div> </div>
@@ -73,110 +33,60 @@ export default function ContactPage() {
<Phone className="w-6 h-6 text-primary" /> <Phone className="w-6 h-6 text-primary" />
<div> <div>
<p className="font-medium">Telefon</p> <p className="font-medium">Telefon</p>
<p className="text-slate-600 dark:text-slate-400">+90 (212) 555 00 00</p> <p className="text-slate-600 dark:text-slate-400">
<a
href={`tel:${(siteSettings.contact_phone || "+90 (212) 555 00 00").replace(/[^\d+]/g, '')}`}
className="hover:text-primary transition-colors hover:underline"
>
{siteSettings.contact_phone || "+90 (212) 555 00 00"}
</a>
</p>
</div> </div>
</div> </div>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<Mail className="w-6 h-6 text-primary" /> <Mail className="w-6 h-6 text-primary" />
<div> <div>
<p className="font-medium">E-posta</p> <p className="font-medium">E-posta</p>
<p className="text-slate-600 dark:text-slate-400">info@parakasa.com</p> <p className="text-slate-600 dark:text-slate-400">
</div> {siteSettings.contact_email || "info@parakasa.com"}
</div>
</div>
<div className="aspect-video bg-slate-100 rounded-lg overflow-hidden relative">
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
Harita (Google Maps Embed)
</div>
</div>
</div>
<Card>
<CardContent className="p-6 sm:p-8">
{isSuccess ? (
<div className="flex flex-col items-center justify-center h-full min-h-[400px] text-center space-y-4">
<CheckCircle className="w-16 h-16 text-green-500" />
<h3 className="text-2xl font-bold">Mesajınız Alındı!</h3>
<p className="text-muted-foreground">
En kısa sürede size dönüş yapacağız.
</p> </p>
<Button onClick={() => setIsSuccess(false)} variant="outline">
Yeni Mesaj Gönder
</Button>
</div>
) : (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium">Adınız</label>
<Input id="name" {...form.register("name")} placeholder="Adınız" />
{form.formState.errors.name && <p className="text-xs text-red-500">{form.formState.errors.name.message}</p>}
</div>
<div className="space-y-2 w-[210px]">
<label htmlFor="surname" className="text-sm font-medium">Soyadınız</label>
<Input id="surname" {...form.register("surname")} placeholder="Soyadınız" />
{form.formState.errors.surname && <p className="text-xs text-red-500">{form.formState.errors.surname.message}</p>}
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">E-posta</label>
<Input id="email" type="email" {...form.register("email")} placeholder="ornek@sirket.com" />
{form.formState.errors.email && <p className="text-xs text-red-500">{form.formState.errors.email.message}</p>}
</div>
<div className="space-y-2">
<label htmlFor="phone" className="text-sm font-medium">Telefon</label>
<div className="relative w-[210px]">
<div className="absolute left-3 top-2 text-muted-foreground text-sm flex items-center gap-2 font-medium z-10 select-none pointer-events-none">
<span>🇹🇷</span>
<span>+90</span>
<div className="w-px h-4 bg-border" />
</div>
<Input
id="phone"
type="tel"
className="pl-20 font-mono"
placeholder="(5XX) XXX XX XX"
maxLength={15}
{...form.register("phone", {
onChange: (e) => {
let value = e.target.value.replace(/\D/g, ''); // Remove non-digits
if (value.startsWith('90')) value = value.slice(2); // Remove leading 90 if user types it
// Format: (5XX) XXX XX XX <div className="pt-4 border-t">
let formattedValue = ''; <h3 className="text-lg font-semibold mb-3">Sosyal Medya</h3>
if (value.length > 0) formattedValue += '(' + value.substring(0, 3); <div className="flex gap-4">
if (value.length > 3) formattedValue += ') ' + value.substring(3, 6); {siteSettings.social_instagram && (
if (value.length > 6) formattedValue += ' ' + value.substring(6, 8); <Link href={siteSettings.social_instagram} target="_blank" className="p-2 bg-slate-100 dark:bg-slate-800 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors text-slate-700 dark:text-slate-300">
if (value.length > 8) formattedValue += ' ' + value.substring(8, 10); <Instagram className="h-5 w-5" />
</Link>
e.target.value = formattedValue;
return e;
}
})}
/>
</div>
{form.formState.errors.phone && <p className="text-xs text-red-500">{form.formState.errors.phone.message}</p>}
</div>
</div>
<div className="space-y-2">
<label htmlFor="subject" className="text-sm font-medium">Konu</label>
<Input id="subject" {...form.register("subject")} placeholder="Konu" />
{form.formState.errors.subject && <p className="text-xs text-red-500">{form.formState.errors.subject.message}</p>}
</div>
<div className="space-y-2">
<label htmlFor="message" className="text-sm font-medium">Mesajınız</label>
<Textarea id="message" {...form.register("message")} placeholder="Size nasıl yardımcı olabiliriz?" className="min-h-[120px]" />
{form.formState.errors.message && <p className="text-xs text-red-500">{form.formState.errors.message.message}</p>}
</div>
<Button size="lg" className="w-full" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Mesaj Gönder
</Button>
</form>
)} )}
</CardContent> {siteSettings.social_youtube && (
</Card> <Link href={siteSettings.social_youtube} target="_blank" className="p-2 bg-slate-100 dark:bg-slate-800 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors text-slate-700 dark:text-slate-300">
<Youtube className="h-5 w-5" />
</Link>
)}
{siteSettings.social_tiktok && (
<Link href={siteSettings.social_tiktok} target="_blank" className="p-2 bg-slate-100 dark:bg-slate-800 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors text-slate-700 dark:text-slate-300">
<FaTiktok className="h-5 w-5" />
</Link>
)}
</div>
</div>
</div>
{siteSettings.contact_map_embed ? (
<div
className="aspect-video bg-slate-100 rounded-lg overflow-hidden relative"
dangerouslySetInnerHTML={{ __html: siteSettings.contact_map_embed }}
/>
) : (
<div className="aspect-video bg-slate-100 rounded-lg overflow-hidden relative flex items-center justify-center text-muted-foreground">
Harita henüz eklenmemiş.
</div>
)}
</div>
<ContactForm />
</div> </div>
</div> </div>
) )

View File

@@ -1,5 +1,5 @@
import Image from "next/image"
export default function CorporatePage() { export default function CorporatePage() {
return ( return (
@@ -8,7 +8,7 @@ export default function CorporatePage() {
<section className="space-y-6 text-center"> <section className="space-y-6 text-center">
<h1 className="text-4xl md:text-5xl font-bold tracking-tight font-outfit">Hakkımızda</h1> <h1 className="text-4xl md:text-5xl font-bold tracking-tight font-outfit">Hakkımızda</h1>
<p className="text-xl text-muted-foreground"> <p className="text-xl text-muted-foreground">
1995'ten beri güvenliğinizi en değerli hazinemiz olarak görüyoruz. 1995&apos;ten beri güvenliğinizi en değerli hazinemiz olarak görüyoruz.
</p> </p>
</section> </section>
@@ -28,7 +28,7 @@ export default function CorporatePage() {
</p> </p>
<h2 className="text-3xl font-bold pt-4">Vizyonumuz</h2> <h2 className="text-3xl font-bold pt-4">Vizyonumuz</h2>
<p className="text-slate-600 dark:text-slate-300 leading-relaxed"> <p className="text-slate-600 dark:text-slate-300 leading-relaxed">
Türkiye'nin lider çelik kasa üreticisi olarak, global pazarda güvenilirlik ve kalite Türkiye&apos;nin lider çelik kasa üreticisi olarak, global pazarda güvenilirlik ve kalite
denince akla gelen ilk marka olmak. Yenilikçi Ar-Ge çalışmalarımızla güvenlik teknolojilerine denince akla gelen ilk marka olmak. Yenilikçi Ar-Ge çalışmalarımızla güvenlik teknolojilerine
yön vermek. yön vermek.
</p> </p>

33
app/(public)/loading.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { Skeleton } from "@/components/ui/skeleton"
export default function PublicLoading() {
return (
<div className="container py-12 md:py-24">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
<div className="space-y-4">
<Skeleton className="aspect-square w-full rounded-xl" />
<div className="grid grid-cols-4 gap-4">
<Skeleton className="aspect-square w-full rounded-xl" />
<Skeleton className="aspect-square w-full rounded-xl" />
<Skeleton className="aspect-square w-full rounded-xl" />
<Skeleton className="aspect-square w-full rounded-xl" />
</div>
</div>
<div className="space-y-8">
<div className="space-y-4">
<Skeleton className="h-8 w-1/3" />
<Skeleton className="h-12 w-2/3" />
</div>
<div className="space-y-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
<div className="space-y-4 pt-6">
<Skeleton className="h-12 w-48" />
</div>
</div>
</div>
</div>
)
}

View File

@@ -34,9 +34,10 @@ export default function LoginPage() {
return return
} }
router.push("/dashboard") // Redirect to 2FA verification instead of dashboard
router.push("/verify-2fa")
router.refresh() router.refresh()
} catch (err: any) { } catch {
setError("Bir hata oluştu. Lütfen tekrar deneyin.") setError("Bir hata oluştu. Lütfen tekrar deneyin.")
} finally { } finally {
setLoading(false) setLoading(false)

View File

@@ -1,13 +1,15 @@
import Link from "next/link" import Link from "next/link"
import Image from "next/image" import Image from "next/image"
import { ArrowRight, ShieldCheck, Lock, Award, History, LayoutDashboard, LogIn } from "lucide-react" import { ArrowRight, ShieldCheck, Lock, History, LayoutDashboard } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { createClient } from "@/lib/supabase-server" import { createClient } from "@/lib/supabase-server"
import { getSiteContents } from "@/lib/data"
export default async function Home() { export default async function Home() {
const supabase = createClient() const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser() const { data: { user } } = await supabase.auth.getUser()
const contents = await getSiteContents()
return ( return (
<div className="flex flex-col min-h-screen"> <div className="flex flex-col min-h-screen">
@@ -15,7 +17,7 @@ export default async function Home() {
<section className="relative w-full h-[80vh] flex items-center bg-black overflow-hidden"> <section className="relative w-full h-[80vh] flex items-center bg-black overflow-hidden">
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
<Image <Image
src="/images/hero-safe.png" src={contents.home_hero_bg_image || "/images/hero-safe.png"}
alt="Premium Çelik Kasa" alt="Premium Çelik Kasa"
fill fill
className="object-cover opacity-60" className="object-cover opacity-60"
@@ -26,16 +28,16 @@ export default async function Home() {
<div className="container relative z-10 px-4 md:px-6"> <div className="container relative z-10 px-4 md:px-6">
<div className="max-w-2xl space-y-4"> <div className="max-w-2xl space-y-4">
<h1 className="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tighter text-white font-outfit"> <h1
GÜVENLİK SINIR <br /> <span className="text-transparent bg-clip-text bg-gradient-to-r from-slate-200 to-slate-500">TANIMAZ</span> className="text-3xl md:text-6xl lg:text-7xl font-bold tracking-tighter text-white font-outfit"
</h1> dangerouslySetInnerHTML={{ __html: contents.home_hero_title || 'GÜVENLİK SINIR <br /> <span class="text-transparent bg-clip-text bg-gradient-to-r from-slate-200 to-slate-500">TANIMAZ</span>' }}
/>
<p className="text-lg md:text-xl text-slate-300 max-w-[600px]"> <p className="text-lg md:text-xl text-slate-300 max-w-[600px]">
En değerli varlıklarınız için tasarlanmış yüksek güvenlikli çelik kasalar. {contents.home_hero_description || "En değerli varlıklarınız için tasarlanmış yüksek güvenlikli çelik kasalar. Modern teknoloji ve zanaatkarlığın mükemmel uyumu."}
Modern teknoloji ve zanaatkarlığın mükemmel uyumu.
</p> </p>
<div className="flex flex-col sm:flex-row gap-4 mt-8"> <div className="flex flex-col sm:flex-row gap-4 mt-8">
<Button size="lg" className="bg-slate-100 text-slate-900 hover:bg-slate-200 font-semibold text-lg px-8"> <Button size="lg" className="bg-slate-100 text-slate-900 hover:bg-slate-200 font-semibold text-lg px-8" asChild>
<Link href="/products">Koleksiyonu İncele</Link> <Link href="/products">{contents.home_hero_button_text || "Koleksiyonu İncele"}</Link>
</Button> </Button>
{user && ( {user && (
<Button size="lg" variant="outline" className="border-slate-400 text-slate-100 hover:bg-slate-800 hover:text-white font-semibold text-lg px-8"> <Button size="lg" variant="outline" className="border-slate-400 text-slate-100 hover:bg-slate-800 hover:text-white font-semibold text-lg px-8">
@@ -54,9 +56,9 @@ export default async function Home() {
<section className="py-20 md:py-32 bg-slate-50 dark:bg-slate-950"> <section className="py-20 md:py-32 bg-slate-50 dark:bg-slate-950">
<div className="container px-4 md:px-6"> <div className="container px-4 md:px-6">
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16"> <div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
<h2 className="text-3xl md:text-5xl font-bold tracking-tighter font-outfit">Ürün Kategorileri</h2> <h2 className="text-3xl md:text-5xl font-bold tracking-tighter font-outfit">{contents.home_categories_title || "Ürün Kategorileri"}</h2>
<p className="max-w-[700px] text-muted-foreground md:text-lg"> <p className="max-w-[700px] text-muted-foreground md:text-lg">
İhtiyacınıza uygun güvenlik çözümünü seçin. {contents.home_categories_description || htiyacınıza uygun güvenlik çözümünü seçin."}
</p> </p>
</div> </div>

View File

@@ -0,0 +1,104 @@
import { createClient } from "@/lib/supabase-server"
import { getSiteContents } from "@/lib/data"
import { notFound } from "next/navigation"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Phone } from "lucide-react"
import { ProductGallery } from "@/components/product/product-gallery"
async function getProduct(id: string) {
const supabase = createClient()
// Fetch product
const { data: product, error } = await supabase
.from("products")
.select("*")
.eq("id", id)
.eq("is_active", true)
.single()
if (error || !product) {
return null
}
// Fetch images
const { data: images } = await supabase
.from("product_images")
.select("*")
.eq("product_id", id)
.order("display_order", { ascending: true })
return { ...product, images: images || [] }
}
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id)
const siteSettings = await getSiteContents()
const whatsappPhone = siteSettings.contact_phone ? siteSettings.contact_phone.replace(/\s+/g, '') : "905555555555"
if (!product) {
notFound()
}
// Combine main image and gallery images for a full list, filtering duplicates if necessary
// Logic: If gallery images exist, use them. If not, fallback to product.image_url.
// If product.image_url is in product_images, we might duplicate, but let's just use all distinct.
let allImages: string[] = []
if (product.images && product.images.length > 0) {
allImages = product.images.map((img: { image_url: string }) => img.image_url)
} else if (product.image_url) {
allImages = [product.image_url]
}
return (
<div className="container py-12 md:py-24">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Image Gallery Section */}
<ProductGallery images={allImages} productName={product.name} />
{/* Product Info Section */}
<div className="space-y-8">
<div>
<div className="flex items-center gap-4 mb-2">
<Badge variant="secondary" className="text-sm uppercase tracking-wider">
{product.category}
</Badge>
{product.product_code && (
<span className="text-sm font-semibold text-slate-500">
Kod: {product.product_code}
</span>
)}
</div>
<h1 className="text-4xl font-bold tracking-tight text-primary font-outfit mb-4">
{product.name}
</h1>
{/* NO PRICE DISPLAY as requested */}
</div>
<div className="prose prose-slate dark:prose-invert max-w-none">
<h3 className="text-lg font-semibold mb-2">Ürün ıklaması</h3>
<p className="text-slate-600 dark:text-slate-300 leading-relaxed whitespace-pre-line">
{product.description || "Bu ürün için henüz detaylııklama eklenmemiştir."}
</p>
</div>
<div className="pt-6 border-t">
<div className="flex flex-col sm:flex-row gap-4">
<Button size="lg" className="w-full sm:w-auto" asChild>
<Link href={`https://wa.me/${whatsappPhone}?text=Merhaba, ${product.name} (Kod: ${product.product_code || "Yok"}) hakkında bilgi almak istiyorum.`} target="_blank">
<Phone className="mr-2 h-5 w-5" />
WhatsApp ile Fiyat Sor
</Link>
</Button>
</div>
<p className="mt-4 text-sm text-slate-500">
* Bu ürün hakkında detaylı bilgi ve fiyat teklifi almak için bizimle iletişime geçebilirsiniz.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,48 +1,31 @@
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import { createClient } from "@/lib/supabase-server"
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import Link from "next/link"
import Image from "next/image" import Image from "next/image"
const products = [
{
id: 1,
name: "Ev Tipi Çelik Kasa",
image: "/images/safe-1.jpg",
category: "Ev",
},
{
id: 2,
name: "Ofis Tipi Yanmaz Kasa",
image: "/images/safe-2.jpg",
category: "Ofis",
},
{
id: 3,
name: "Otel Odası Kasası",
image: "/images/safe-3.jpg",
category: "Otel",
},
{
id: 4,
name: "Silah Kasası (Tüfek)",
image: "/images/safe-4.jpg",
category: "Özel",
},
{
id: 5,
name: "Kuyumcu Kasası",
image: "/images/safe-5.jpg",
category: "Ticari",
},
{
id: 6,
name: "Duvar İçi Gizli Kasa",
image: "/images/safe-6.jpg",
category: "Ev",
},
]
export default function ProductsPage() {
// Helper to get products
async function getProducts() {
const supabase = createClient()
const { data, error } = await supabase
.from("products")
.select("*")
.eq("is_active", true)
.order("created_at", { ascending: false })
if (error) {
console.error("Error fetching products:", error)
return []
}
return data
}
export default async function ProductsPage() {
const products = await getProducts()
return ( return (
<div className="container py-12 md:py-24"> <div className="container py-12 md:py-24">
<div className="flex flex-col items-center mb-12 text-center"> <div className="flex flex-col items-center mb-12 text-center">
@@ -53,27 +36,50 @@ export default function ProductsPage() {
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map((product) => ( {products && products.length > 0 ? (
<Card key={product.id} className="overflow-hidden border-0 shadow-md hover:shadow-xl transition-all duration-300"> products.map((product) => (
<Card key={product.id} className="overflow-hidden border-0 shadow-md hover:shadow-xl transition-all duration-300 group">
<div className="aspect-[4/5] relative bg-slate-100 dark:bg-slate-800"> <div className="aspect-[4/5] relative bg-slate-100 dark:bg-slate-800">
{/* Placeholder for real images */} {product.image_url ? (
<Image
src={product.image_url}
alt={product.name}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-slate-400"> <div className="absolute inset-0 flex items-center justify-center text-slate-400">
<span className="text-sm">Görsel: {product.name}</span> <span className="text-sm">Görsel Yok</span>
</div> </div>
)}
</div> </div>
<CardHeader className="p-4"> <CardHeader className="p-4">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <div className="w-full">
<div className="flex justify-between items-center w-full">
<span className="text-xs font-medium text-slate-500 uppercase tracking-wider">{product.category}</span> <span className="text-xs font-medium text-slate-500 uppercase tracking-wider">{product.category}</span>
{product.product_code && (
<span className="text-xs font-semibold text-slate-400">#{product.product_code}</span>
)}
</div>
<CardTitle className="text-lg mt-1">{product.name}</CardTitle> <CardTitle className="text-lg mt-1">{product.name}</CardTitle>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardFooter className="p-4 pt-0"> <CardFooter className="p-4 pt-0">
<Button className="w-full">Detayları İncele</Button> <Button className="w-full" variant="outline" asChild>
<Link href={`/products/${product.id}`}>
Detayları İncele
</Link>
</Button>
</CardFooter> </CardFooter>
</Card> </Card>
))} ))
) : (
<div className="col-span-full text-center py-12">
<p className="text-muted-foreground">Henüz vitrinde ürünümüz bulunmuyor.</p>
</div>
)}
</div> </div>
</div> </div>
) )

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link" import Link from "next/link"
import { supabase } from "@/lib/supabase" import { supabase } from "@/lib/supabase"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -11,7 +11,6 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { AlertCircle, Loader2, CheckCircle2 } from "lucide-react" import { AlertCircle, Loader2, CheckCircle2 } from "lucide-react"
export default function SignUpPage() { export default function SignUpPage() {
const router = useRouter()
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -40,7 +39,7 @@ export default function SignUpPage() {
if (data.user) { if (data.user) {
setSuccess(true) setSuccess(true)
} }
} catch (err: any) { } catch {
setError("Bir hata oluştu. Lütfen tekrar deneyin.") setError("Bir hata oluştu. Lütfen tekrar deneyin.")
} finally { } finally {
setLoading(false) setLoading(false)
@@ -76,7 +75,7 @@ export default function SignUpPage() {
<CardHeader className="space-y-1"> <CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold">Hesap Oluştur</CardTitle> <CardTitle className="text-2xl font-bold">Hesap Oluştur</CardTitle>
<CardDescription> <CardDescription>
ParaKasa'ya katılmak için bilgilerinizi girin ParaKasa&apos;ya katılmak için bilgilerinizi girin
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>

View File

@@ -0,0 +1,130 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { AlertCircle, Loader2 } from "lucide-react"
import { sendVerificationCode, verifyCode } from "@/lib/sms/verification-actions"
import { createClient } from "@/lib/supabase-browser"
export default function Verify2FAPage() {
const router = useRouter()
const [code, setCode] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [sent, setSent] = useState(false)
const [maskedPhone, setMaskedPhone] = useState("")
useEffect(() => {
// Init: Send code automatically
const init = async () => {
setLoading(true)
const result = await sendVerificationCode()
setLoading(false)
if (result.error) {
setError(result.error)
} else {
setSent(true)
setMaskedPhone(result.phone || "")
}
}
init()
}, [])
const handleVerify = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
const result = await verifyCode(code)
if (result.error) {
setError(result.error)
} else {
router.push("/dashboard")
router.refresh()
}
} catch {
setError("Bir hata oluştu.")
} finally {
setLoading(false)
}
}
const handleResend = async () => {
setLoading(true)
setError(null)
const result = await sendVerificationCode()
setLoading(false)
if (result.error) {
setError(result.error)
} else {
setSent(true)
}
}
return (
<div className="flex items-center justify-center min-h-screen bg-slate-50 dark:bg-slate-950 px-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold">SMS Doğrulama</CardTitle>
<CardDescription>
{sent ? `Telefonunuza (***${maskedPhone}) gönderilen 6 haneli kodu girin.` : "Doğrulama kodu gönderiliyor..."}
</CardDescription>
</CardHeader>
<CardContent>
{error && (
<div className="mb-4 flex items-center space-x-2 text-sm text-red-600 bg-red-50 p-3 rounded-md">
<AlertCircle className="h-4 w-4" />
<span>{error}</span>
</div>
)}
<form onSubmit={handleVerify} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="code">Doğrulama Kodu</Label>
<Input
id="code"
type="text"
placeholder="123456"
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={6}
className="text-center text-lg tracking-widest"
required
/>
</div>
<Button className="w-full" type="submit" disabled={loading || !sent}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Doğrula
</Button>
</form>
</CardContent>
<CardFooter className="flex flex-col gap-4 text-center text-sm text-muted-foreground">
<button
type="button"
onClick={handleResend}
disabled={loading}
className="underline underline-offset-4 hover:text-primary disabled:opacity-50"
>
Kodu Tekrar Gönder
</button>
<button
type="button"
onClick={async () => {
const supabase = createClient()
await supabase.auth.signOut()
router.push("/login")
}}
className="text-xs"
>
Giriş ekranına dön
</button>
</CardFooter>
</Card>
</div>
)
}

View File

@@ -1,3 +1,4 @@
@import 'react-phone-number-input/style.css';
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;

View File

@@ -1,26 +1,50 @@
import type { Metadata } from "next";
import { Inter, Outfit } from "next/font/google"; import localFont from "next/font/local";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { getSiteContents } from "@/lib/data";
const inter = Inter({ subsets: ["latin"] }); const inter = localFont({
const outfit = Outfit({ subsets: ["latin"], variable: "--font-outfit" }); src: [
{ path: "../public/fonts/Inter-Thin.ttf", weight: "100", style: "normal" },
{ path: "../public/fonts/Inter-ExtraLight.ttf", weight: "200", style: "normal" },
{ path: "../public/fonts/Inter-Light.ttf", weight: "300", style: "normal" },
{ path: "../public/fonts/Inter-Regular.ttf", weight: "400", style: "normal" },
{ path: "../public/fonts/Inter-Medium.ttf", weight: "500", style: "normal" },
{ path: "../public/fonts/Inter-SemiBold.ttf", weight: "600", style: "normal" },
{ path: "../public/fonts/Inter-Bold.ttf", weight: "700", style: "normal" },
{ path: "../public/fonts/Inter-ExtraBold.ttf", weight: "800", style: "normal" },
{ path: "../public/fonts/Inter-Black.ttf", weight: "900", style: "normal" },
],
variable: "--font-inter",
});
const outfit = localFont({
src: [
{ path: "../public/fonts/Outfit-Thin.ttf", weight: "100", style: "normal" },
{ path: "../public/fonts/Outfit-ExtraLight.ttf", weight: "200", style: "normal" },
{ path: "../public/fonts/Outfit-Light.ttf", weight: "300", style: "normal" },
{ path: "../public/fonts/Outfit-Regular.ttf", weight: "400", style: "normal" },
{ path: "../public/fonts/Outfit-Medium.ttf", weight: "500", style: "normal" },
{ path: "../public/fonts/Outfit-SemiBold.ttf", weight: "600", style: "normal" },
{ path: "../public/fonts/Outfit-Bold.ttf", weight: "700", style: "normal" },
{ path: "../public/fonts/Outfit-ExtraBold.ttf", weight: "800", style: "normal" },
{ path: "../public/fonts/Outfit-Black.ttf", weight: "900", style: "normal" },
],
variable: "--font-outfit",
});
import { getSiteSettings } from "@/lib/site-settings";
export async function generateMetadata() { export async function generateMetadata() {
const settings = await getSiteSettings(); const settings = await getSiteContents();
return { return {
title: settings?.site_title || "ParaKasa - Premium Çelik Kasalar", title: settings.site_title || "ParaKasa - Premium Çelik Kasalar",
description: settings?.site_description || "Eviniz ve iş yeriniz için en yüksek güvenlikli çelik kasa ve para sayma çözümleri.", description: settings.site_description || "Eviniz ve iş yeriniz için en yüksek güvenlikli çelik kasa ve para sayma çözümleri.",
}; };
} }
import { ThemeProvider } from "@/components/theme-provider"
// ... imports
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{

BIN
build_log.txt Normal file

Binary file not shown.

BIN
build_log_2.txt Normal file

Binary file not shown.

View File

@@ -0,0 +1,137 @@
"use client"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Card, CardContent } from "@/components/ui/card"
import { Loader2, CheckCircle } from "lucide-react"
import { contactFormSchema, ContactFormValues } from "@/lib/schemas"
import { submitContactForm } from "@/lib/actions/contact"
import { toast } from "sonner"
export function ContactForm() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const form = useForm<ContactFormValues>({
resolver: zodResolver(contactFormSchema),
defaultValues: {
name: "",
surname: "",
email: "",
phone: "",
subject: "",
message: "",
},
})
async function onSubmit(data: ContactFormValues) {
setIsSubmitting(true)
try {
const response = await submitContactForm(data)
if (response.success) {
setIsSuccess(true)
form.reset()
toast.success("Mesajınız başarıyla gönderildi.")
} else {
toast.error("Hata: " + response.error)
}
} catch {
toast.error("Bir hata oluştu.")
} finally {
setIsSubmitting(false)
}
}
return (
<Card>
<CardContent className="p-6 sm:p-8">
{isSuccess ? (
<div className="flex flex-col items-center justify-center h-full min-h-[400px] text-center space-y-4">
<CheckCircle className="w-16 h-16 text-green-500" />
<h3 className="text-2xl font-bold">Mesajınız Alındı!</h3>
<p className="text-muted-foreground">
En kısa sürede size dönüş yapacağız.
</p>
<Button onClick={() => setIsSuccess(false)} variant="outline">
Yeni Mesaj Gönder
</Button>
</div>
) : (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium">Adınız</label>
<Input id="name" {...form.register("name")} placeholder="Adınız" />
{form.formState.errors.name && <p className="text-xs text-red-500">{form.formState.errors.name.message}</p>}
</div>
<div className="space-y-2 w-full">
<label htmlFor="surname" className="text-sm font-medium">Soyadınız</label>
<Input id="surname" {...form.register("surname")} placeholder="Soyadınız" />
{form.formState.errors.surname && <p className="text-xs text-red-500">{form.formState.errors.surname.message}</p>}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">E-posta</label>
<Input id="email" type="email" {...form.register("email")} placeholder="ornek@sirket.com" />
{form.formState.errors.email && <p className="text-xs text-red-500">{form.formState.errors.email.message}</p>}
</div>
<div className="space-y-2">
<label htmlFor="phone" className="text-sm font-medium">Telefon</label>
<div className="relative w-full">
<div className="absolute left-3 top-2 text-muted-foreground text-sm flex items-center gap-2 font-medium z-10 select-none pointer-events-none">
<span>🇹🇷</span>
<span>+90</span>
<div className="w-px h-4 bg-border" />
</div>
<Input
id="phone"
type="tel"
className="pl-20 font-mono"
placeholder="(5XX) XXX XX XX"
maxLength={15}
{...form.register("phone", {
onChange: (e) => {
let value = e.target.value.replace(/\D/g, ''); // Remove non-digits
if (value.startsWith('90')) value = value.slice(2); // Remove leading 90 if user types it
// Format: (5XX) XXX XX XX
let formattedValue = '';
if (value.length > 0) formattedValue += '(' + value.substring(0, 3);
if (value.length > 3) formattedValue += ') ' + value.substring(3, 6);
if (value.length > 6) formattedValue += ' ' + value.substring(6, 8);
if (value.length > 8) formattedValue += ' ' + value.substring(8, 10);
e.target.value = formattedValue;
return e;
}
})}
/>
</div>
{form.formState.errors.phone && <p className="text-xs text-red-500">{form.formState.errors.phone.message}</p>}
</div>
</div>
<div className="space-y-2">
<label htmlFor="subject" className="text-sm font-medium">Konu</label>
<Input id="subject" {...form.register("subject")} placeholder="Konu" />
{form.formState.errors.subject && <p className="text-xs text-red-500">{form.formState.errors.subject.message}</p>}
</div>
<div className="space-y-2">
<label htmlFor="message" className="text-sm font-medium">Mesajınız</label>
<Textarea id="message" {...form.register("message")} placeholder="Size nasıl yardımcı olabiliriz?" className="min-h-[120px]" />
{form.formState.errors.message && <p className="text-xs text-red-500">{form.formState.errors.message.message}</p>}
</div>
<Button size="lg" className="w-full" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Mesaj Gönder
</Button>
</form>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,60 @@
"use client"
import { useEffect, useCallback, useRef } from "react"
import { useRouter } from "next/navigation"
import { createClient } from "@/lib/supabase-browser"
import { toast } from "sonner"
const INACTIVITY_TIMEOUT = 15 * 60 * 1000 // 15 minutes
export function AutoLogoutHandler() {
const router = useRouter()
const supabase = createClient()
const timerRef = useRef<NodeJS.Timeout | null>(null)
const handleLogout = useCallback(async () => {
await supabase.auth.signOut()
toast.info("Oturumunuz uzun süre işlem yapılmadığı için sonlandırıldı.")
router.push("/login")
router.refresh()
}, [router, supabase])
const resetTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
timerRef.current = setTimeout(handleLogout, INACTIVITY_TIMEOUT)
}, [handleLogout])
useEffect(() => {
// Events to listen for
const events = [
"mousedown",
"mousemove",
"keydown",
"scroll",
"touchstart",
]
// Initial set
resetTimer()
// Event listener wrapper to debounce slightly/reset
const onUserActivity = () => {
resetTimer()
}
events.forEach((event) => {
window.addEventListener(event, onUserActivity)
})
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
events.forEach((event) => {
window.removeEventListener(event, onUserActivity)
})
}
}, [resetTimer])
return null
}

View File

@@ -62,8 +62,8 @@ export function CategoryForm({ initialData }: CategoryFormProps) {
try { try {
if (initialData) { if (initialData) {
const result = await updateCategory(initialData.id, data) const result = await updateCategory(initialData.id, data)
if ((result as any).error) { if ('error' in result) {
toast.error((result as any).error) toast.error(result.error)
} else { } else {
toast.success(toastMessage) toast.success(toastMessage)
router.push(`/dashboard/categories`) router.push(`/dashboard/categories`)
@@ -71,15 +71,15 @@ export function CategoryForm({ initialData }: CategoryFormProps) {
} }
} else { } else {
const result = await createCategory(data) const result = await createCategory(data)
if ((result as any).error) { if ('error' in result) {
toast.error((result as any).error) toast.error(result.error)
} else { } else {
toast.success(toastMessage) toast.success(toastMessage)
router.push(`/dashboard/categories`) router.push(`/dashboard/categories`)
router.refresh() router.refresh()
} }
} }
} catch (error) { } catch {
toast.error("Bir hata oluştu.") toast.error("Bir hata oluştu.")
} finally { } finally {
setLoading(false) setLoading(false)
@@ -90,14 +90,14 @@ export function CategoryForm({ initialData }: CategoryFormProps) {
setLoading(true) setLoading(true)
try { try {
const result = await deleteCategory(initialData!.id) const result = await deleteCategory(initialData!.id)
if ((result as any).error) { if ('error' in result) {
toast.error((result as any).error) toast.error(result.error)
} else { } else {
toast.success("Kategori silindi.") toast.success("Kategori silindi.")
router.push(`/dashboard/categories`) router.push(`/dashboard/categories`)
router.refresh() router.refresh()
} }
} catch (error) { } catch {
toast.error("Silme işlemi başarısız.") toast.error("Silme işlemi başarısız.")
} finally { } finally {
setLoading(false) setLoading(false)

View File

@@ -0,0 +1,130 @@
'use client'
import { useState } from "react"
import { SiteContent } from "@/types/cms"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { ImageUpload } from "@/components/ui/image-upload" // Ensure this exists or adapt
import { updateSiteContent } from "@/app/(dashboard)/dashboard/cms/content/actions"
import { toast } from "sonner"
import { Save, Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
interface ContentFormProps {
initialContent: SiteContent[]
}
const SECTIONS = [
{ id: 'general', label: 'Genel Ayarlar' },
{ id: 'home', label: 'Anasayfa' },
{ id: 'contact', label: 'İletişim' },
{ id: 'seo', label: 'SEO Ayarları' },
{ id: 'scripts', label: 'Script & Analitik' },
]
export function ContentForm({ initialContent }: ContentFormProps) {
const [contents, setContents] = useState<SiteContent[]>(initialContent)
const [loading, setLoading] = useState(false)
const [activeSection, setActiveSection] = useState('general')
const handleChange = (key: string, value: string) => {
setContents(prev => prev.map(item =>
item.key === key ? { ...item, value } : item
))
}
const onSubmit = async () => {
setLoading(true)
try {
const result = await updateSiteContent(contents)
if (result.success) {
toast.success("İçerikler başarıyla güncellendi")
} else {
toast.error(result.error)
}
} catch {
toast.error("Bir hata oluştu")
} finally {
setLoading(false)
}
}
const filteredContent = contents.filter(item => item.section === activeSection)
return (
<div className="space-y-6">
{/* Custom Tabs */}
<div className="flex space-x-1 rounded-lg bg-muted p-1 overflow-x-auto">
{SECTIONS.map((section) => (
<button
key={section.id}
onClick={() => setActiveSection(section.id)}
className={cn(
"flex-1 justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
activeSection === section.id
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:bg-background/50 hover:text-foreground"
)}
>
{section.label}
</button>
))}
</div>
<Card>
<CardHeader>
<CardTitle>{SECTIONS.find(s => s.id === activeSection)?.label}</CardTitle>
<CardDescription>
Bu bölümdeki içerikleri aşağıdan düzenleyebilirsiniz.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{filteredContent.length === 0 && (
<p className="text-sm text-muted-foreground">Bu bölümde henüz ayar bulunmuyor.</p>
)}
{filteredContent.map((item) => (
<div key={item.key} className="space-y-2">
<Label className="capitalize">
{item.key.replace(/_/g, ' ').replace(activeSection, '').trim() || item.key}
</Label>
{item.type === 'image_url' ? (
<ImageUpload
value={item.value}
onChange={(url) => handleChange(item.key, url)}
onRemove={() => handleChange(item.key, '')}
/>
) : item.type === 'long_text' || item.type === 'html' || item.key.includes('address') ? (
<Textarea
value={item.value}
onChange={(e) => handleChange(item.key, e.target.value)}
rows={4}
/>
) : (
<Input
value={item.value}
onChange={(e) => handleChange(item.key, e.target.value)}
/>
)}
{item.key.includes('map_embed') && (
<p className="text-xs text-muted-foreground mt-1">
Google Maps&apos;den alınan &lt;iframe&gt; kodunu buraya yapıştırın.
</p>
)}
</div>
))}
</CardContent>
</Card>
<div className="flex justify-end sticky bottom-6 z-10">
<Button onClick={onSubmit} disabled={loading} size="lg" className="shadow-lg">
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
Değişiklikleri Kaydet
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,187 @@
'use client'
import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { Customer, CustomerInsert } from "@/types/customer"
import { addCustomer, updateCustomer } from "@/lib/customers/actions"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Plus, Loader2 } from "lucide-react"
import { toast } from "sonner"
const formSchema = z.object({
full_name: z.string().min(2, "Ad soyad en az 2 karakter olmalıdır"),
email: z.string().email("Geçersiz e-posta adresi").or(z.literal("")).optional(),
phone: z.string().optional(),
address: z.string().optional(),
notes: z.string().optional(),
})
interface CustomerFormProps {
customer?: Customer
trigger?: React.ReactNode
}
export function CustomerForm({ customer, trigger }: CustomerFormProps) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
full_name: customer?.full_name || "",
email: customer?.email || "",
phone: customer?.phone || "",
address: customer?.address || "",
notes: customer?.notes || "",
},
})
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true)
try {
const customerData = {
...values,
email: values.email || null,
phone: values.phone || null,
address: values.address || null,
notes: values.notes || null,
} as CustomerInsert
let result
if (customer) {
result = await updateCustomer(customer.id, customerData)
} else {
result = await addCustomer(customerData)
}
if (result.success) {
toast.success(customer ? "Müşteri güncellendi" : "Müşteri eklendi")
setOpen(false)
form.reset()
} else {
toast.error(result.error)
}
} catch {
toast.error("Bir hata oluştu")
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger || (
<Button>
<Plus className="mr-2 h-4 w-4" /> Yeni Müşteri
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{customer ? "Müşteriyi Düzenle" : "Yeni Müşteri Ekle"}</DialogTitle>
<DialogDescription>
Müşteri bilgilerini aşağıdan yönetebilirsiniz.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="full_name"
render={({ field }) => (
<FormItem>
<FormLabel>Ad Soyad</FormLabel>
<FormControl>
<Input placeholder="Ad Soyad" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>E-Posta</FormLabel>
<FormControl>
<Input placeholder="ornek@site.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Telefon</FormLabel>
<FormControl>
<Input placeholder="0555..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="address"
render={({ field }) => (
<FormItem>
<FormLabel>Adres</FormLabel>
<FormControl>
<Textarea placeholder="Adres..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Özel Notlar</FormLabel>
<FormControl>
<Textarea placeholder="Notlar..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Kaydet
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@@ -6,7 +6,12 @@ import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
import { Sidebar } from "@/components/dashboard/sidebar" import { Sidebar } from "@/components/dashboard/sidebar"
import { UserNav } from "@/components/dashboard/user-nav" import { UserNav } from "@/components/dashboard/user-nav"
export function DashboardHeader() { interface DashboardHeaderProps {
user: { email?: string | null } | null
profile: { full_name?: string | null, role?: string | null } | null
}
export function DashboardHeader({ user, profile }: DashboardHeaderProps) {
return ( return (
<header className="sticky top-0 z-30 flex h-14 items-center gap-4 border-b bg-background px-4 sm:static sm:h-auto sm:border-0 sm:bg-transparent sm:px-6"> <header className="sticky top-0 z-30 flex h-14 items-center gap-4 border-b bg-background px-4 sm:static sm:h-auto sm:border-0 sm:bg-transparent sm:px-6">
<Sheet> <Sheet>
@@ -28,7 +33,7 @@ export function DashboardHeader() {
<div className="w-full flex-1"> <div className="w-full flex-1">
{/* Breadcrumb or Search could go here */} {/* Breadcrumb or Search could go here */}
</div> </div>
<UserNav /> <UserNav user={user} profile={profile} />
</header> </header>
) )
} }

View File

@@ -0,0 +1,113 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { toast } from "sonner"
import { Loader2 } from "lucide-react"
import { supabase } from "@/lib/supabase"
const passwordSchema = z.object({
password: z.string().min(6, "Şifre en az 6 karakter olmalıdır."),
confirmPassword: z.string().min(6, "Şifre tekrarı en az 6 karakter olmalıdır."),
}).refine((data) => data.password === data.confirmPassword, {
message: "Şifreler eşleşmiyor.",
path: ["confirmPassword"],
})
type PasswordFormValues = z.infer<typeof passwordSchema>
export function PasswordForm() {
const router = useRouter()
const [loading, setLoading] = useState(false)
const form = useForm<PasswordFormValues>({
resolver: zodResolver(passwordSchema),
defaultValues: {
password: "",
confirmPassword: "",
},
})
const onSubmit = async (data: PasswordFormValues) => {
setLoading(true)
try {
const { error } = await supabase.auth.updateUser({
password: data.password
})
if (error) {
toast.error("Şifre güncellenemedi: " + error.message)
return
}
toast.success("Şifreniz başarıyla güncellendi.")
form.reset()
router.refresh()
} catch {
toast.error("Bir sorun oluştu.")
} finally {
setLoading(false)
}
}
return (
<Card>
<CardHeader>
<CardTitle>Yeni Şifre Belirle</CardTitle>
<CardDescription>
Hesabınız için yeni bir şifre belirleyin.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Yeni Şifre</FormLabel>
<FormControl>
<Input type="password" placeholder="******" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Şifre Tekrar</FormLabel>
<FormControl>
<Input type="password" placeholder="******" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Şifreyi Güncelle
</Button>
</form>
</Form>
</CardContent>
</Card>
)
}

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import { useForm } from "react-hook-form" import { useForm, type Resolver } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod" import * as z from "zod"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -23,31 +23,44 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" import { toast } from "sonner"
import { Loader2 } from "lucide-react" import { Loader2, X, UploadCloud } from "lucide-react"
import imageCompression from 'browser-image-compression'
import { createClient } from "@/lib/supabase-browser"
import Image from "next/image"
import { createProduct, updateProduct } from "@/app/(dashboard)/dashboard/products/actions"
const productSchema = z.object({ const productSchema = z.object({
name: z.string().min(2, "Ürün adı en az 2 karakter olmalıdır"), name: z.string().min(2, "Ürün adı en az 2 karakter olmalıdır"),
product_code: z.string().optional(),
category: z.string().min(1, "Kategori seçiniz"), category: z.string().min(1, "Kategori seçiniz"),
description: z.string().optional(), description: z.string().optional(),
price: z.coerce.number().min(0, "Fiyat 0'dan küçük olamaz"), price: z.coerce.number().min(0, "Fiyat 0'dan küçük olamaz"),
image_url: z.string().optional(), image_url: z.string().optional(),
is_active: z.boolean().default(true),
images: z.array(z.string()).optional()
}) })
type ProductFormValues = z.infer<typeof productSchema> type ProductFormValues = z.infer<typeof productSchema>
import { createProduct, updateProduct } from "@/app/(dashboard)/dashboard/products/actions"
// Define the shape of data coming from Supabase // Define the shape of data coming from Supabase
interface Product { interface Product {
id: number id: number
name: string name: string
product_code?: string | null
category: string category: string
description: string | null description: string | null
price: number price: number
image_url: string | null image_url: string | null
created_at: string created_at: string
is_active?: boolean
// images? we might need to fetch them separately if they are in another table,
// but for now let's assume update passes them if fetched, or we can handle it later.
// Ideally the server component fetches relation.
} }
interface ProductFormProps { interface ProductFormProps {
@@ -57,24 +70,120 @@ interface ProductFormProps {
export function ProductForm({ initialData }: ProductFormProps) { export function ProductForm({ initialData }: ProductFormProps) {
const router = useRouter() const router = useRouter()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [uploading, setUploading] = useState(false)
const [previewImages, setPreviewImages] = useState<string[]>(
initialData?.image_url ? [initialData.image_url] : []
)
// Note: initialData probably only has single image_url field unless we updated the fetch query.
// For MVP phase 1, we just sync with image_url or expect 'images' prop if we extended it.
// I will add a local state for images.
const form = useForm<ProductFormValues>({ const form = useForm<ProductFormValues>({
resolver: zodResolver(productSchema) as any, resolver: zodResolver(productSchema) as Resolver<ProductFormValues>,
defaultValues: initialData ? { defaultValues: initialData ? {
name: initialData.name, name: initialData.name,
product_code: initialData.product_code || "",
category: initialData.category, category: initialData.category,
description: initialData.description || "", description: initialData.description || "",
price: initialData.price, price: initialData.price,
image_url: initialData.image_url || "", image_url: initialData.image_url || "",
is_active: initialData.is_active ?? true,
images: initialData.image_url ? [initialData.image_url] : []
} : { } : {
name: "", name: "",
product_code: "",
category: "", category: "",
description: "", description: "",
price: 0, price: 0,
image_url: "", image_url: "",
is_active: true,
images: []
}, },
}) })
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files
if (!files || files.length === 0) return
setUploading(true)
const supabase = createClient()
const uploadedUrls: string[] = [...form.getValues("images") || []]
try {
for (let i = 0; i < files.length; i++) {
const file = files[i]
// Compression
const options = {
maxSizeMB: 1, // Max 1MB
maxWidthOrHeight: 1920,
useWebWorker: true
}
let compressedFile = file
try {
compressedFile = await imageCompression(file, options)
} catch (error) {
console.error("Compression error:", error)
// Fallback to original
}
// Upload
const fileExt = file.name.split('.').pop()
const fileName = `${Math.random().toString(36).substring(2)}_${Date.now()}.${fileExt}`
const filePath = `products/${fileName}`
const { error: uploadError } = await supabase.storage
.from('products') // Assuming 'products' bucket exists
.upload(filePath, compressedFile)
if (uploadError) {
console.error(uploadError)
toast.error(`Resim yüklenemedi: ${file.name}`)
continue
}
// Get URL
const { data } = supabase.storage.from('products').getPublicUrl(filePath)
uploadedUrls.push(data.publicUrl)
}
// Update form
form.setValue("images", uploadedUrls)
// Set first image as main
if (uploadedUrls.length > 0) {
form.setValue("image_url", uploadedUrls[0])
}
setPreviewImages(uploadedUrls)
} catch {
toast.error("Yükleme sırasında hata oluştu")
} finally {
setUploading(false)
}
}
const removeImage = async (index: number) => {
const imageToDelete = previewImages[index]
// If editing an existing product and image is from server (starts with http/https usually)
// Local state removal only. Server update happens on Save.
// if (initialData && imageToDelete.startsWith("http")) { ... }
const currentImages = [...form.getValues("images") || []]
// Filter out the deleted image URL if it matches
const newImages = currentImages.filter(url => url !== imageToDelete)
form.setValue("images", newImages)
if (newImages.length > 0) {
form.setValue("image_url", newImages[0])
} else {
form.setValue("image_url", "")
}
setPreviewImages(newImages)
}
async function onSubmit(data: ProductFormValues) { async function onSubmit(data: ProductFormValues) {
try { try {
setLoading(true) setLoading(true)
@@ -94,7 +203,7 @@ export function ProductForm({ initialData }: ProductFormProps) {
toast.success(initialData ? "Ürün güncellendi" : "Ürün başarıyla oluşturuldu") toast.success(initialData ? "Ürün güncellendi" : "Ürün başarıyla oluşturuldu")
router.push("/dashboard/products") router.push("/dashboard/products")
router.refresh() router.refresh()
} catch (error) { } catch {
toast.error("Bir aksilik oldu") toast.error("Bir aksilik oldu")
} finally { } finally {
setLoading(false) setLoading(false)
@@ -104,6 +213,31 @@ export function ProductForm({ initialData }: ProductFormProps) {
return ( return (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 w-full max-w-2xl"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 w-full max-w-2xl">
<div className="flex items-center justify-between">
<FormField
control={form.control}
name="is_active"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4 shadow-sm w-full max-w-xs">
<div className="space-y-0.5">
<FormLabel className="text-base">Aktif Durum</FormLabel>
<FormDescription>
Ürün sitede görüntülensin mi?
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
@@ -117,8 +251,22 @@ export function ProductForm({ initialData }: ProductFormProps) {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="product_code"
render={({ field }) => (
<FormItem>
<FormLabel>Ürün Kodu</FormLabel>
<FormControl>
<Input placeholder="KOD-123" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField <FormField
control={form.control} control={form.control}
name="category" name="category"
@@ -143,6 +291,7 @@ export function ProductForm({ initialData }: ProductFormProps) {
</FormItem> </FormItem>
)} )}
/> />
</div>
<FormField <FormField
control={form.control} control={form.control}
@@ -157,24 +306,54 @@ export function ProductForm({ initialData }: ProductFormProps) {
</FormItem> </FormItem>
)} )}
/> />
<div className="space-y-4">
<FormLabel>Ürün Görselleri</FormLabel>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{previewImages.map((url, i) => (
<div key={i} className="relative aspect-square border rounded-md overflow-hidden group">
<Image
src={url}
alt="Preview"
fill
className="object-cover"
/>
<button
type="button"
onClick={() => removeImage(i)}
className="absolute top-1 right-1 bg-red-500 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-4 h-4" />
</button>
</div>
))}
<label className="flex flex-col items-center justify-center w-full aspect-square border-2 border-dashed rounded-lg cursor-pointer hover:bg-muted/50 transition-colors">
<div className="flex flex-col items-center justify-center pt-5 pb-6">
{uploading ? (
<Loader2 className="w-8 h-8 text-gray-500 animate-spin" />
) : (
<UploadCloud className="w-8 h-8 text-gray-500" />
)}
<p className="mb-2 text-sm text-gray-500">Resim Yükle</p>
</div>
<input
type="file"
className="hidden"
multiple
accept="image/*"
onChange={handleImageUpload}
disabled={uploading}
/>
</label>
</div>
<FormDescription>
Birden fazla resim seçebilirsiniz. Resimler otomatik olarak sıkıştırılacaktır.
</FormDescription>
</div> </div>
<FormField {/* Hidden input for main image url fallback if needed */}
control={form.control} <input type="hidden" {...form.register("image_url")} />
name="image_url"
render={({ field }) => (
<FormItem>
<FormLabel>Görsel URL (Opsiyonel)</FormLabel>
<FormControl>
<Input placeholder="https://..." {...field} />
</FormControl>
<FormDescription>
Ürün görseli için şimdilik dış bağlantı kullanın.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
@@ -190,8 +369,8 @@ export function ProductForm({ initialData }: ProductFormProps) {
)} )}
/> />
<Button type="submit" disabled={loading}> <Button type="submit" disabled={loading || uploading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {(loading || uploading) && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{initialData ? "Güncelle" : "Oluştur"} {initialData ? "Güncelle" : "Oluştur"}
</Button> </Button>
</form> </form>

View File

@@ -0,0 +1,80 @@
"use client"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { SmsSettingsForm } from "@/components/dashboard/sms-settings-form"
import { AppearanceForm } from "@/components/dashboard/appearance-form"
import { UsersTable, Profile } from "@/components/dashboard/users-table"
import { ContentForm } from "@/components/dashboard/content-form"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { SiteContent } from "@/types/cms"
interface SettingsTabsProps {
smsSettings: {
username: string
header: string
} | null
users: Profile[]
contents: SiteContent[]
}
export function SettingsTabs({ smsSettings, users, contents }: SettingsTabsProps) {
return (
<Tabs defaultValue="content" className="space-y-4">
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-5 h-auto gap-1">
<TabsTrigger value="content">İçerik Yönetimi</TabsTrigger>
<TabsTrigger value="users">Kullanıcılar</TabsTrigger>
<TabsTrigger value="sms">SMS / Bildirimler</TabsTrigger>
<TabsTrigger value="appearance">Görünüm</TabsTrigger>
<TabsTrigger value="security">Güvenlik</TabsTrigger>
</TabsList>
<TabsContent value="content" className="space-y-4">
<div className="space-y-4">
<div>
<h3 className="text-lg font-medium">Site İçerik Yönetimi</h3>
<p className="text-sm text-muted-foreground">
Site genel ayarları, iletişim bilgileri ve logolar.
</p>
</div>
<ContentForm initialContent={contents} />
</div>
</TabsContent>
<TabsContent value="users" className="space-y-4">
<div className="space-y-4">
<div>
<h3 className="text-lg font-medium">Kullanıcı Yönetimi</h3>
<p className="text-sm text-muted-foreground">
Sistemdeki kayıtlı kullanıcıları ve rollerini yönetin.
</p>
</div>
<UsersTable users={users} />
</div>
</TabsContent>
<TabsContent value="sms" className="space-y-4">
<SmsSettingsForm initialData={smsSettings} />
</TabsContent>
<TabsContent value="appearance" className="space-y-4">
<AppearanceForm />
</TabsContent>
<TabsContent value="security" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Hesap Güvenliği</CardTitle>
<CardDescription>
Şifre ve oturum yönetimi.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button variant="outline" className="w-full">Şifre Değiştir</Button>
<Button variant="destructive" className="w-full">Hesabı Sil</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
)
}

View File

@@ -3,7 +3,7 @@
import Link from "next/link" import Link from "next/link"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { LayoutDashboard, Package, ShoppingCart, Users, Settings, Globe, Tags } from "lucide-react" import { LayoutDashboard, Package, ShoppingCart, Settings, Globe, Tags, Users, MessageSquare, History } from "lucide-react"
const sidebarItems = [ const sidebarItems = [
{ {
@@ -16,21 +16,26 @@ const sidebarItems = [
href: "/dashboard/products", href: "/dashboard/products",
icon: Package, icon: Package,
}, },
{
title: "Siparişler",
href: "/dashboard/orders",
icon: ShoppingCart,
},
{ {
title: "Kategoriler", title: "Kategoriler",
href: "/dashboard/categories", href: "/dashboard/categories",
icon: Tags, icon: Tags,
}, },
{ {
title: "Kullanıcılar", title: "Siparişler",
href: "/dashboard/users", href: "/dashboard/orders",
icon: ShoppingCart,
},
{
title: "Müşteriler",
href: "/dashboard/customers",
icon: Users, icon: Users,
}, },
{
title: "SMS Gönder",
href: "/dashboard/sms",
icon: MessageSquare,
},
{ {
title: "Ayarlar", title: "Ayarlar",
href: "/dashboard/settings", href: "/dashboard/settings",
@@ -43,7 +48,7 @@ const sidebarItems = [
}, },
] ]
interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> { } type SidebarProps = React.HTMLAttributes<HTMLDivElement>
export function Sidebar({ className }: SidebarProps) { export function Sidebar({ className }: SidebarProps) {
const pathname = usePathname() const pathname = usePathname()
@@ -56,7 +61,7 @@ export function Sidebar({ className }: SidebarProps) {
Yönetim Yönetim
</h2> </h2>
<div className="space-y-1"> <div className="space-y-1">
{sidebarItems.map((item) => ( {sidebarItems.filter(i => !i.href.includes('/sms')).map((item) => (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
@@ -71,6 +76,34 @@ export function Sidebar({ className }: SidebarProps) {
))} ))}
</div> </div>
</div> </div>
<div className="px-3 py-2">
<h2 className="mb-2 px-4 text-lg font-semibold tracking-tight">
SMS
</h2>
<div className="space-y-1">
<Link
href="/dashboard/sms"
className={cn(
"flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors",
pathname === "/dashboard/sms" ? "bg-slate-100 dark:bg-slate-800 text-primary" : "text-slate-500 dark:text-slate-400"
)}
>
<MessageSquare className="mr-2 h-4 w-4" />
Yeni SMS
</Link>
<Link
href="/dashboard/sms/logs"
className={cn(
"flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors",
pathname === "/dashboard/sms/logs" ? "bg-slate-100 dark:bg-slate-800 text-primary" : "text-slate-500 dark:text-slate-400"
)}
>
<History className="mr-2 h-4 w-4" />
Geçmiş
</Link>
</div>
</div>
</div> </div>
</div> </div>
) )

View File

@@ -1,164 +0,0 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { toast } from "sonner"
import { Loader2 } from "lucide-react"
import { updateSiteSettings } from "@/app/(dashboard)/dashboard/settings/actions"
const settingsSchema = z.object({
site_title: z.string().min(2, "Site başlığı en az 2 karakter olmalıdır."),
site_description: z.string(),
contact_email: z.literal("").or(z.string().email("Geçerli bir e-posta adresi giriniz.")),
contact_phone: z.string(),
currency: z.string(),
})
type SettingsFormValues = z.infer<typeof settingsSchema>
interface SiteSettingsFormProps {
initialData: any
}
export function SiteSettingsForm({ initialData }: SiteSettingsFormProps) {
const router = useRouter()
const [loading, setLoading] = useState(false)
const form = useForm<SettingsFormValues>({
resolver: zodResolver(settingsSchema),
defaultValues: {
site_title: initialData?.site_title || "ParaKasa",
site_description: initialData?.site_description || "",
contact_email: initialData?.contact_email || "",
contact_phone: initialData?.contact_phone || "",
currency: initialData?.currency || "TRY",
},
})
const onSubmit = async (data: SettingsFormValues) => {
setLoading(true)
try {
// @ts-ignore
const result = await updateSiteSettings(data)
if (result.error) {
toast.error(result.error)
return
}
toast.success("Site ayarları güncellendi.")
router.refresh()
} catch (error) {
toast.error("Bir sorun oluştu.")
} finally {
setLoading(false)
}
}
return (
<Card>
<CardHeader>
<CardTitle>Genel Ayarlar</CardTitle>
<CardDescription>Web sitesinin genel yapılandırma ayarları.</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="site_title"
render={({ field }) => (
<FormItem>
<FormLabel>Site Başlığı</FormLabel>
<FormControl>
<Input placeholder="ParaKasa" {...field} />
</FormControl>
<FormDescription>Tarayıcı sekmesinde görünen ad.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="site_description"
render={({ field }) => (
<FormItem>
<FormLabel>Site ıklaması</FormLabel>
<FormControl>
<Textarea placeholder="Premium çelik kasalar..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="contact_email"
render={({ field }) => (
<FormItem>
<FormLabel>İletişim E-posta</FormLabel>
<FormControl>
<Input placeholder="info@parakasa.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contact_phone"
render={({ field }) => (
<FormItem>
<FormLabel>İletişim Telefon</FormLabel>
<FormControl>
<Input placeholder="+90 555 123 45 67" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="currency"
render={({ field }) => (
<FormItem>
<FormLabel>Para Birimi</FormLabel>
<FormControl>
<Input placeholder="TRY" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Ayarları Kaydet
</Button>
</form>
</Form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,213 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { useForm, type Resolver } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Checkbox } from "@/components/ui/checkbox"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { toast } from "sonner"
import { Loader2 } from "lucide-react"
import { ImageUpload } from "@/components/ui/image-upload"
import { createSlider, updateSlider } from "@/app/(dashboard)/dashboard/sliders/actions"
const sliderSchema = z.object({
title: z.string().min(2, "Başlık en az 2 karakter olmalıdır"),
description: z.string().optional(),
image_url: z.string().min(1, "Görsel yüklemek zorunludur"),
link: z.string().optional(),
order: z.coerce.number().default(0),
is_active: z.boolean().default(true),
})
type SliderFormValues = z.infer<typeof sliderSchema>
interface Slider {
id: string
title: string
description: string | null
image_url: string
link: string | null
order: number | null
is_active: boolean | null
}
interface SliderFormProps {
initialData?: Slider
}
export function SliderForm({ initialData }: SliderFormProps) {
const router = useRouter()
const [loading, setLoading] = useState(false)
const form = useForm<SliderFormValues>({
resolver: zodResolver(sliderSchema) as Resolver<SliderFormValues>,
defaultValues: initialData ? {
title: initialData.title,
description: initialData.description || "",
image_url: initialData.image_url,
link: initialData.link || "",
order: initialData.order || 0,
is_active: initialData.is_active ?? true,
} : {
title: "",
description: "",
image_url: "",
link: "",
order: 0,
is_active: true,
},
})
async function onSubmit(data: SliderFormValues) {
setLoading(true)
try {
let result
if (initialData) {
result = await updateSlider(initialData.id, data)
} else {
result = await createSlider(data)
}
if (result.error) {
toast.error(result.error)
return
}
toast.success(initialData ? "Slider güncellendi" : "Slider oluşturuldu")
router.push("/dashboard/sliders")
router.refresh()
} catch {
toast.error("Bir sorun oluştu.")
} finally {
setLoading(false)
}
}
return (
<Card>
<CardHeader>
<CardTitle>{initialData ? "Slider Düzenle" : "Yeni Slider Ekle"}</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
name="image_url"
render={({ field }) => (
<FormItem>
<FormLabel>Görsel</FormLabel>
<FormControl>
<ImageUpload
value={field.value}
onChange={field.onChange}
onRemove={() => field.onChange("")}
disabled={loading}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Başlık</FormLabel>
<FormControl>
<Input placeholder="Örn: Yeni Sezon Modelleri" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="order"
render={({ field }) => (
<FormItem>
<FormLabel>Sıralama</FormLabel>
<FormControl>
<Input type="number" {...field} />
</FormControl>
<FormDescription>Düşük numara önce gösterilir (0, 1, 2...)</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>ıklama</FormLabel>
<FormControl>
<Textarea placeholder="Kısa açıklama metni..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="link"
render={({ field }) => (
<FormItem>
<FormLabel>Yönlendirme Linki (Opsiyonel)</FormLabel>
<FormControl>
<Input placeholder="/kategori/ev-tipi" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
name="is_active"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
Aktif
</FormLabel>
<FormDescription>
Bu slider ana sayfada gösterilsin mi?
</FormDescription>
</div>
</FormItem>
)}
/>
<Button type="submit" disabled={loading} className="w-full sm:w-auto">
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{initialData ? "Kaydet" : "Oluştur"}
</Button>
</form>
</Form>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,480 @@
"use client"
import { useState, useEffect } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { Customer } from "@/types/customer"
import { sendBulkSms } from "@/lib/sms/actions"
import { getTemplates, createTemplate, deleteTemplate, SmsTemplate } from "@/lib/sms/templates"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Loader2, Send, Save, Trash2, BookOpen, Smartphone } from "lucide-react"
import { toast } from "sonner"
import { ScrollArea } from "@/components/ui/scroll-area"
const formSchema = z.object({
manualNumbers: z.string().optional(),
message: z.string().min(1, "Mesaj içeriği boş olamaz").max(900, "Mesaj çok uzun (max 900 karakter)"),
selectedCustomers: z.array(z.string()).optional()
})
interface SmsPageProps {
customers: Customer[]
}
export default function SmsPageClient({ customers }: SmsPageProps) {
const [loading, setLoading] = useState(false)
const [templates, setTemplates] = useState<SmsTemplate[]>([])
// Template Management States
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false)
const [newTemplateTitle, setNewTemplateTitle] = useState("")
const [newTemplateMessage, setNewTemplateMessage] = useState("")
const [templateLoading, setTemplateLoading] = useState(false)
// Contact Picker States
const [isContactModalOpen, setIsContactModalOpen] = useState(false)
const [searchTerm, setSearchTerm] = useState("")
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
manualNumbers: "",
message: "",
selectedCustomers: []
},
})
async function handleNativeContactPicker() {
if (!('contacts' in navigator && 'ContactsManager' in window)) {
toast.error("Rehber özelliği desteklenmiyor (HTTPS gerekli olabilir).")
return
}
try {
const props = ['tel'];
const opts = { multiple: true };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const contacts = await (navigator as any).contacts.select(props, opts);
if (contacts && contacts.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newNumbers = contacts.map((contact: any) => {
const phone = contact.tel?.[0]
return phone ? phone.replace(/\s/g, '') : null;
}).filter(Boolean).join(", ");
if (newNumbers) {
const current = form.getValues("manualNumbers");
const updated = current ? `${current}, ${newNumbers}` : newNumbers;
form.setValue("manualNumbers", updated);
toast.success(`${contacts.length} numara eklendi.`);
}
}
} catch (ex) {
console.error(ex);
}
}
// Load templates on mount
useEffect(() => {
loadTemplates()
}, [])
async function loadTemplates() {
const result = await getTemplates()
if (result.success && result.data) {
setTemplates(result.data)
}
}
async function handleSaveTemplate() {
if (!newTemplateTitle || !newTemplateMessage) {
toast.error("Başlık ve mesaj zorunludur")
return
}
setTemplateLoading(true)
const result = await createTemplate(newTemplateTitle, newTemplateMessage)
setTemplateLoading(false)
if (result.success) {
toast.success("Şablon kaydedildi")
setNewTemplateTitle("")
setNewTemplateMessage("")
setIsTemplateModalOpen(false)
loadTemplates()
} else {
toast.error(result.error || "Şablon kaydedilemedi")
}
}
async function handleDeleteTemplate(id: string) {
if (!confirm("Bu şablonu silmek istediğinize emin misiniz?")) return
const result = await deleteTemplate(id)
if (result.success) {
toast.success("Şablon silindi")
loadTemplates()
} else {
toast.error("Şablon silinemedi")
}
}
const handleSelectTemplate = (id: string) => {
const template = templates.find(t => t.id === id)
if (template) {
form.setValue("message", template.message)
}
}
// Filter customers for contact picker
const filteredCustomers = customers.filter(c =>
c.full_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
c.phone?.includes(searchTerm)
)
const toggleCustomerSelection = (phone: string) => {
const current = form.getValues("selectedCustomers") || []
if (current.includes(phone)) {
form.setValue("selectedCustomers", current.filter(p => p !== phone))
} else {
form.setValue("selectedCustomers", [...current, phone])
}
}
const selectAllFiltered = () => {
const current = form.getValues("selectedCustomers") || []
const newPhones = filteredCustomers.map(c => c.phone).filter(Boolean) as string[]
// Merge unique
const merged = Array.from(new Set([...current, ...newPhones]))
form.setValue("selectedCustomers", merged)
}
const deselectAll = () => {
form.setValue("selectedCustomers", [])
}
async function onSubmit(values: z.infer<typeof formSchema>) {
const manualPhones = values.manualNumbers
?.split(/[,\n]/)
.map(p => p.trim())
.filter(p => p !== "") || []
const customerPhones = values.selectedCustomers || []
const allPhones = [...manualPhones, ...customerPhones]
if (allPhones.length === 0) {
toast.error("Lütfen en az bir alıcı seçin veya numara girin.")
return
}
setLoading(true)
try {
const result = await sendBulkSms(allPhones, values.message)
if (result.success) {
toast.success(result.message)
form.reset({
manualNumbers: "",
message: "",
selectedCustomers: []
})
} else {
toast.error(result.error || "SMS gönderilirken hata oluştu")
}
} catch {
toast.error("Bir hata oluştu")
} finally {
setLoading(false)
}
}
const watchedSelected = form.watch("selectedCustomers") || []
return (
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">SMS Gönderimi</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card className="md:col-span-2 lg:col-span-1">
<CardHeader>
<CardTitle>Mesaj Bilgileri</CardTitle>
<CardDescription>
Toplu veya tekil SMS gönderin. (Türkçe karakter desteklenir)
</CardDescription>
</CardHeader>
<CardContent>
<form id="sms-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Templates Section */}
<div className="flex items-end gap-2">
<div className="flex-1 space-y-2">
<Label>Hazır Şablonlar</Label>
<Select onValueChange={handleSelectTemplate}>
<SelectTrigger>
<SelectValue placeholder="Şablon seçin..." />
</SelectTrigger>
<SelectContent>
{templates.map(t => (
<SelectItem key={t.id} value={t.id}>{t.title}</SelectItem>
))}
{templates.length === 0 && <div className="p-2 text-sm text-muted-foreground">Henüz şablon yok</div>}
</SelectContent>
</Select>
</div>
<Dialog open={isTemplateModalOpen} onOpenChange={setIsTemplateModalOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="icon">
<Save className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Yeni SMS Şablonu</DialogTitle>
<DialogDescription>
Sık kullandığınız mesajları şablon olarak kaydedin.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Şablon Adı</Label>
<Input
placeholder="Örn: Bayram Kutlaması"
value={newTemplateTitle}
onChange={e => setNewTemplateTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Mesaj İçeriği</Label>
<Textarea
placeholder="Mesajınız..."
value={newTemplateMessage}
onChange={e => setNewTemplateMessage(e.target.value)}
/>
</div>
{templates.length > 0 && (
<div className="pt-4 border-t">
<h4 className="text-sm font-medium mb-2">Kayıtlı Şablonlar</h4>
<ScrollArea className="h-32 rounded border p-2">
{templates.map(t => (
<div key={t.id} className="flex items-center justify-between text-sm py-1">
<span>{t.title}</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-red-500"
onClick={() => handleDeleteTemplate(t.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</ScrollArea>
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setIsTemplateModalOpen(false)}>İptal</Button>
<Button type="button" onClick={handleSaveTemplate} disabled={templateLoading}>Kaydet</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="space-y-2">
<FormLabel label="Gönderilecek Mesaj" />
<Textarea
className="min-h-[120px]"
placeholder="Mesajınızı buraya yazın..."
{...form.register("message")}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>Türkçe karakterler otomatik desteklenir.</span>
<span>{form.watch("message")?.length || 0} / 900</span>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Manuel Numaralar</Label>
<Button
type="button"
variant="default"
onClick={handleNativeContactPicker}
>
<Smartphone className="mr-2 h-4 w-4" />
Telefondan Seç
</Button>
</div>
<Textarea
placeholder="5551234567 (Her satıra bir numara)"
className="min-h-[80px]"
{...form.register("manualNumbers")}
/>
</div>
<div className="pt-2">
<div className="p-4 bg-slate-50 dark:bg-slate-900 rounded-md">
<h4 className="font-semibold mb-2">Özet</h4>
<ul className="list-disc list-inside text-sm">
<li>Manuel: {(form.watch("manualNumbers")?.split(/[,\n]/).filter(x => x.trim()).length || 0)} Kişi</li>
<li>Seçili Müşteri: {watchedSelected.length} Kişi</li>
<li className="font-bold mt-1">Toplam: {(form.watch("manualNumbers")?.split(/[,\n]/).filter(x => x.trim()).length || 0) + watchedSelected.length} Kişi</li>
</ul>
</div>
</div>
</form>
</CardContent>
<CardFooter>
<Button form="sms-form" type="submit" disabled={loading} className="w-full">
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
Gönderimi Başlat
</Button>
</CardFooter>
</Card>
{/* Contact Picker Section */}
<Card className="md:col-span-2 lg:col-span-1 h-fit">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Müşteri Rehberi</span>
<div className="flex gap-2">
<Dialog open={isContactModalOpen} onOpenChange={setIsContactModalOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<BookOpen className="mr-2 h-4 w-4" />
Rehberi
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Müşteri Seçimi</DialogTitle>
<DialogDescription>
Listeden SMS göndermek istediğiniz müşterileri seçin.
</DialogDescription>
</DialogHeader>
<div className="flex items-center space-x-2 py-4">
<Input
placeholder="İsim veya telefon ile ara..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex gap-2 mb-2">
<Button size="sm" variant="outline" onClick={selectAllFiltered}>Görünenleri Seç</Button>
<Button size="sm" variant="ghost" onClick={deselectAll}>Temizle</Button>
</div>
<ScrollArea className="flex-1 pr-4">
<div className="space-y-2">
{filteredCustomers.length === 0 && <p className="text-center text-muted-foreground py-4">Sonuç bulunamadı.</p>}
{filteredCustomers.map((customer) => (
<div
key={customer.id}
className="flex items-center space-x-3 p-3 rounded-lg border hover:bg-slate-50 dark:hover:bg-slate-900 cursor-pointer transition-colors"
onClick={() => toggleCustomerSelection(customer.phone || "")}
>
<Checkbox
checked={watchedSelected.includes(customer.phone || "")}
onCheckedChange={() => { }} // Handle by parent div click
id={`modal-customer-${customer.id}`}
/>
<div className="flex-1">
<div className="font-medium">{customer.full_name}</div>
<div className="text-sm text-muted-foreground">{customer.phone}</div>
</div>
</div>
))}
</div>
</ScrollArea>
<DialogFooter className="mt-4">
<div className="flex items-center justify-between w-full">
<span className="text-sm text-muted-foreground">
{watchedSelected.length} kişi seçildi
</span>
<Button onClick={() => setIsContactModalOpen(false)}>Tamam</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardTitle>
<CardDescription>
Hızlı seçim veya detaylı arama yapın.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<Input
placeholder="Hızlı ara..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<ScrollArea className="h-[400px] w-full pr-4 border rounded-md p-2">
<div className="space-y-2">
{filteredCustomers.map((customer) => (
<div
key={customer.id}
className="flex items-center space-x-3 p-2 rounded hover:bg-slate-50 dark:hover:bg-slate-900 cursor-pointer"
onClick={() => toggleCustomerSelection(customer.phone || "")}
>
<Checkbox
checked={watchedSelected.includes(customer.phone || "")}
id={`list-customer-${customer.id}`}
className="mt-0.5"
/>
<div className="grid gap-0.5 leading-none">
<label
htmlFor={`list-customer-${customer.id}`}
className="font-medium cursor-pointer"
>
{customer.full_name}
</label>
<p className="text-xs text-muted-foreground">
{customer.phone || "Telefon Yok"}
</p>
</div>
</div>
))}
{filteredCustomers.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-2">Sonuç yok.</p>
)}
</div>
</ScrollArea>
</div>
</CardContent>
</Card>
</div>
</div>
)
}
function FormLabel({ label }: { label: string }) {
return <Label>{label}</Label>
}

View File

@@ -0,0 +1,198 @@
"use client"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { toast } from "sonner"
import { Loader2, Send } from "lucide-react"
import { updateSmsSettings, sendTestSms } from "@/lib/sms/actions"
const smsSettingsSchema = z.object({
username: z.string().min(1, "Kullanıcı adı gereklidir."),
password: z.string().optional(),
header: z.string().min(1, "Başlık (Gönderici Adı) gereklidir."),
})
type SmsSettingsValues = z.infer<typeof smsSettingsSchema>
interface SmsSettingsFormProps {
initialData: {
username: string
header: string
} | null
}
export function SmsSettingsForm({ initialData }: SmsSettingsFormProps) {
const [loading, setLoading] = useState(false)
const [testLoading, setTestLoading] = useState(false)
const [testPhone, setTestPhone] = useState("")
const form = useForm<SmsSettingsValues>({
resolver: zodResolver(smsSettingsSchema),
defaultValues: {
username: initialData?.username || "",
header: initialData?.header || "",
password: "",
},
})
const onSubmit = async (data: SmsSettingsValues) => {
setLoading(true)
try {
const result = await updateSmsSettings({
username: data.username,
password: data.password,
header: data.header,
})
if (result.error) {
toast.error(result.error)
return
}
toast.success("SMS ayarları güncellendi.")
// Don't reset form fully, keeps values visible except password
form.setValue("password", "")
} catch {
toast.error("Bir sorun oluştu.")
} finally {
setLoading(false)
}
}
const onTestSms = async () => {
if (!testPhone) {
toast.error("Lütfen bir test numarası girin.")
return
}
setTestLoading(true)
try {
const result = await sendTestSms(testPhone)
if (result.error) {
toast.error("Test başarısız: " + result.error)
} else {
toast.success("Test SMS başarıyla gönderildi!")
}
} catch {
toast.error("Test sırasında bir hata oluştu.")
} finally {
setTestLoading(false)
}
}
return (
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>NetGSM Konfigürasyonu</CardTitle>
<CardDescription>
NetGSM API bilgilerinizi buradan yönetebilirsiniz. Şifre alanı sadece değiştirmek istediğinizde gereklidir.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>NetGSM Kullanıcı Adı (850...)</FormLabel>
<FormControl>
<Input placeholder="850xxxxxxx" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Şifre</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormDescription>
Mevcut şifreyi korumak için boş bırakın.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="header"
render={({ field }) => (
<FormItem>
<FormLabel>Mesaj Başlığı (Gönderici Adı)</FormLabel>
<FormControl>
<Input placeholder="PARAKASA" {...field} />
</FormControl>
<FormDescription>
NetGSM panelinde tanımlı gönderici adınız.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Ayarları Kaydet
</Button>
</form>
</Form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Bağlantı Testi</CardTitle>
<CardDescription>
Ayarların doğru çalıştığını doğrulamak için test SMS gönderin.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-4">
<div className="grid gap-2 flex-1">
<Input
placeholder="5551234567"
value={testPhone}
onChange={(e) => setTestPhone(e.target.value)}
/>
<p className="text-[0.8rem] text-muted-foreground">
Başında 0 olmadan 10 hane giriniz.
</p>
</div>
<Button variant="secondary" onClick={onTestSms} disabled={testLoading || !testPhone}>
{testLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Test Gönder
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,12 +1,13 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import { useRouter } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod" import * as z from "zod"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { PhoneInput } from "@/components/ui/phone-input"
import { import {
Form, Form,
FormControl, FormControl,
@@ -25,35 +26,59 @@ import {
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { toast } from "sonner" import { toast } from "sonner"
import { Loader2 } from "lucide-react" import { Loader2 } from "lucide-react"
import { createUser, updateUser } from "@/app/(dashboard)/dashboard/users/actions" import { createUser, updateUser, updateProfile } from "@/app/(dashboard)/dashboard/users/actions"
const userSchema = z.object({ const userSchema = z.object({
firstName: z.string().min(2, "Ad en az 2 karakter olmalıdır."), firstName: z.string().min(2, "Ad en az 2 karakter olmalıdır."),
lastName: z.string().min(2, "Soyad en az 2 karakter olmalıdır."), lastName: z.string().min(2, "Soyad en az 2 karakter olmalıdır."),
email: z.string().email("Geçerli bir e-posta adresi giriniz."), email: z.string().email("Geçerli bir e-posta adresi giriniz."),
password: z.string().optional(), // Password is optional on edit password: z.string().optional(),
confirmPassword: z.string().optional(),
role: z.enum(["admin", "user"]), role: z.enum(["admin", "user"]),
}).refine((data) => { phone: z.string().optional(),
// If we are creating a NEW user (no ID passed in props effectively, but schema doesn't know props), }).superRefine((data, ctx) => {
// we generally want password required. But here we'll handle it in the component logic or strictly separate schemas. // 1. Password match check
// For simplicity, we make password optional in Zod but check it in onSubmit if it's a create action. if (data.password && data.password !== data.confirmPassword) {
return true ctx.addIssue({
}) code: z.ZodIssueCode.custom,
message: "Şifreler eşleşmiyor.",
path: ["confirmPassword"],
});
}
// 2. New user password requirement check
// If there is no ID (we can't easily check for ID here in schema without context,
// but typically empty password on CREATE is invalid unless handled elsewhere.
// However, the component logic implies 'initialData' determines edit/create mode.
// For pure schema validation, we often make password required for create, optional for edit.
// Since we don't pass 'isCreate' to schema, we can enforce minimum length if provided.
if (data.password && data.password.length > 0 && data.password.length < 6) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Şifre en az 6 karakter olmalıdır.",
path: ["password"],
});
}
});
type UserFormValues = z.infer<typeof userSchema> type UserFormValues = z.infer<typeof userSchema>
interface UserFormProps { interface UserFormProps {
initialData?: { initialData?: {
id: string id?: string
firstName: string firstName: string
lastName: string lastName: string
email: string email: string
role: "admin" | "user" role: "admin" | "user"
phone?: string
} }
mode?: "admin" | "profile"
} }
export function UserForm({ initialData }: UserFormProps) { export function UserForm({ initialData, mode = "admin" }: UserFormProps) {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams()
const returnTo = searchParams.get("returnTo")
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const form = useForm<UserFormValues>({ const form = useForm<UserFormValues>({
@@ -63,13 +88,17 @@ export function UserForm({ initialData }: UserFormProps) {
lastName: initialData.lastName, lastName: initialData.lastName,
email: initialData.email, email: initialData.email,
password: "", // Empty password means no change password: "", // Empty password means no change
confirmPassword: "",
role: initialData.role, role: initialData.role,
phone: initialData.phone,
} : { } : {
firstName: "", firstName: "",
lastName: "", lastName: "",
email: "", email: "",
password: "", password: "",
confirmPassword: "",
role: "user", role: "user",
phone: "",
}, },
}) })
@@ -77,17 +106,28 @@ export function UserForm({ initialData }: UserFormProps) {
setLoading(true) setLoading(true)
try { try {
let result; let result;
if (initialData) {
// Update if (mode === "profile") {
result = await updateUser(initialData.id, data) // Profile update mode (self-service)
result = await updateProfile({
firstName: data.firstName,
lastName: data.lastName,
phone: data.phone
})
} else if (initialData?.id) {
// Admin update mode
result = await updateUser(initialData.id, {
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
password: data.password,
role: data.role,
phone: data.phone
})
} else { } else {
// Create // Admin create mode
if (!data.password || data.password.length < 6) { // Password requirement is now handled by Zod Schema
toast.error("Yeni kullanıcı için şifre gereklidir (min 6 karakter).") result = await createUser(data.firstName, data.lastName, data.email, data.password!, data.role, data.phone)
setLoading(false)
return
}
result = await createUser(data.firstName, data.lastName, data.email, data.password, data.role)
} }
if (result.error) { if (result.error) {
@@ -95,10 +135,20 @@ export function UserForm({ initialData }: UserFormProps) {
return return
} }
toast.success(initialData ? "Kullanıcı güncellendi." : "Kullanıcı oluşturuldu.") toast.success(
router.push("/dashboard/users") mode === "profile"
? "Profil bilgileriniz güncellendi."
: initialData ? "Kullanıcı güncellendi." : "Kullanıcı oluşturuldu."
)
router.refresh() router.refresh()
} catch (error) { router.refresh()
if (returnTo) {
router.push(returnTo)
} else if (mode === "admin") {
router.push("/dashboard/users")
}
} catch {
toast.error("Bir sorun oluştu.") toast.error("Bir sorun oluştu.")
} finally { } finally {
setLoading(false) setLoading(false)
@@ -141,18 +191,40 @@ export function UserForm({ initialData }: UserFormProps) {
<FormField <FormField
control={form.control} control={form.control}
name="email" name="phone"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>E-posta</FormLabel> <FormLabel>Telefon</FormLabel>
<FormControl> <FormControl>
<Input placeholder="ahmet@parakasa.com" {...field} /> <PhoneInput placeholder="555 123 4567" defaultCountry="TR" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>E-posta</FormLabel>
<FormControl>
<Input
placeholder="ahmet@parakasa.com"
{...field}
disabled={mode === "profile"}
className={mode === "profile" ? "bg-muted" : ""}
/>
</FormControl>
{mode === "profile" && <p className="text-[0.8rem] text-muted-foreground">E-posta adresi değiştirilemez.</p>}
<FormMessage />
</FormItem>
)}
/>
{mode === "admin" && (
<>
<FormField <FormField
control={form.control} control={form.control}
name="password" name="password"
@@ -167,6 +239,20 @@ export function UserForm({ initialData }: UserFormProps) {
)} )}
/> />
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Şifre Tekrar</FormLabel>
<FormControl>
<Input type="password" placeholder="******" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="role" name="role"
@@ -188,10 +274,19 @@ export function UserForm({ initialData }: UserFormProps) {
</FormItem> </FormItem>
)} )}
/> />
</>
)}
{mode === "profile" && (
<div className="space-y-1">
<FormLabel>Rol</FormLabel>
<Input value={initialData?.role === 'admin' ? 'Yönetici' : 'Kullanıcı'} disabled className="bg-muted" />
</div>
)}
<Button type="submit" disabled={loading} className="w-full"> <Button type="submit" disabled={loading} className="w-full">
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Kullanıcı Oluştur {mode === "profile" ? "Değişiklikleri Kaydet" : (initialData ? "Kaydet" : "Kullanıcı Oluştur")}
</Button> </Button>
</form> </form>
</Form> </Form>

View File

@@ -14,61 +14,85 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { supabase } from "@/lib/supabase" import { createBrowserClient } from "@supabase/ssr"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
export function UserNav() {
interface UserNavProps {
user: {
email?: string | null
} | null
profile: {
full_name?: string | null
role?: string | null
} | null
}
export function UserNav({ user, profile }: UserNavProps) {
const router = useRouter() const router = useRouter()
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
const handleSignOut = async () => { const handleSignOut = async () => {
await supabase.auth.signOut() await supabase.auth.signOut()
router.push("/login") router.push("/")
router.refresh() router.refresh()
} }
const getInitials = (name: string) => {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.substring(0, 2)
}
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full"> <Button variant="ghost" className="relative h-9 w-9 rounded-full ring-2 ring-primary/10 ring-offset-2 hover:ring-primary/20 transition-all">
<Avatar className="h-8 w-8"> <Avatar className="h-9 w-9">
<AvatarImage src="/avatars/01.png" alt="@parakasa" /> <AvatarImage src="" alt={profile?.full_name || "@parakasa"} />
<AvatarFallback>PK</AvatarFallback> <AvatarFallback className="bg-gradient-to-br from-blue-600 to-indigo-600 text-white font-bold text-xs">
{profile?.full_name
? getInitials(profile.full_name)
: user?.email
? user.email.substring(0, 2).toUpperCase()
: 'PK'}
</AvatarFallback>
</Avatar> </Avatar>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount> <DropdownMenuContent className="w-56" align="end">
<DropdownMenuLabel className="font-normal"> <DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">Admin</p> <p className="text-sm font-medium leading-none">{profile?.full_name || 'Kullanıcı'}</p>
<p className="text-xs leading-none text-muted-foreground"> <p className="text-xs leading-none text-muted-foreground">
admin@parakasa.com {user?.email}
</p> </p>
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href="/dashboard/profile"> <Link href="/dashboard/profile">
<DropdownMenuItem className="cursor-pointer"> Profil Bilgileri
Profil
</DropdownMenuItem>
</Link> </Link>
<Link href="/dashboard/users">
<DropdownMenuItem className="cursor-pointer">
Kullanıcılar
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href="/dashboard/profile/password">
Şifre Değiştir
</Link> </Link>
<Link href="/dashboard/settings">
<DropdownMenuItem className="cursor-pointer">
Ayarlar
</DropdownMenuItem> </DropdownMenuItem>
</Link>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut}> <DropdownMenuItem onClick={handleSignOut} className="cursor-pointer text-red-600 focus:text-red-600">
Çıkış Yap Çıkış
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -0,0 +1,67 @@
"use client"
import { Button } from "@/components/ui/button"
import Link from "next/link"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
export interface Profile {
id: string
full_name: string
role: string
created_at: string
}
interface UsersTableProps {
users: Profile[]
}
export function UsersTable({ users }: UsersTableProps) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between space-y-2">
<h3 className="text-lg font-medium">Kullanıcı Listesi</h3>
<Link href="/dashboard/users/new?returnTo=/dashboard/settings">
<Button size="sm">Yeni Kullanıcı</Button>
</Link>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Ad Soyad</TableHead>
<TableHead>Rol</TableHead>
<TableHead>Kayıt Tarihi</TableHead>
<TableHead className="text-right">İşlemler</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users?.map((profile) => (
<TableRow key={profile.id}>
<TableCell className="font-medium">{profile.full_name}</TableCell>
<TableCell>
<Badge variant={profile.role === 'admin' ? 'default' : 'secondary'}>
{profile.role === 'admin' ? 'Yönetici' : 'Kullanıcı'}
</Badge>
</TableCell>
<TableCell>{new Date(profile.created_at).toLocaleDateString('tr-TR')}</TableCell>
<TableCell className="text-right">
<Link href={`/dashboard/users/${profile.id}`}>
<Button variant="ghost" size="sm">Düzenle</Button>
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}

View File

@@ -1,15 +1,20 @@
import Link from "next/link" import Link from "next/link"
import { getSiteContents } from "@/lib/data"
import { Instagram, Youtube } from "lucide-react"
import { FaTiktok } from "react-icons/fa"
export async function Footer() {
const siteSettings = await getSiteContents()
export function Footer() {
return ( return (
<footer className="w-full border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-12 md:py-24 lg:py-32"> <footer className="w-full border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-12 md:py-24 lg:py-32">
<div className="container grid gap-8 px-4 md:px-6 lg:grid-cols-4"> <div className="container grid gap-8 px-4 md:px-6 lg:grid-cols-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Link href="/" className="flex items-center gap-2"> <Link href="/" className="flex items-center gap-2">
<span className="text-xl font-bold tracking-tighter">PARAKASA</span> <span className="text-xl font-bold tracking-tighter">{siteSettings.site_title || "PARAKASA"}</span>
</Link> </Link>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Yüksek güvenlikli çelik kasa ve para sayma sistemleri için güvenilir çözüm ortağınız. {siteSettings.site_description || "Yüksek güvenlikli çelik kasa ve para sayma sistemleri için güvenilir çözüm ortağınız."}
</p> </p>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -44,19 +49,36 @@ export function Footer() {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h3 className="font-semibold">Bize Ulaşın</h3> <h3 className="font-semibold">Bize Ulaşın</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground whitespace-pre-wrap">
İstanbul, Türkiye {siteSettings.contact_address || "İstanbul, Türkiye"}
</p> </p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
+90 212 000 00 00 {siteSettings.contact_phone || "+90 212 000 00 00"}
</p>
<p className="text-sm text-muted-foreground">
{siteSettings.contact_email}
</p> </p>
<div className="flex gap-4 mt-4"> <div className="flex gap-4 mt-4">
{/* Social Media Icons would go here */} {siteSettings.social_instagram && (
<Link href={siteSettings.social_instagram} target="_blank" className="text-muted-foreground hover:text-foreground">
<Instagram className="h-5 w-5" />
</Link>
)}
{siteSettings.social_youtube && (
<Link href={siteSettings.social_youtube} target="_blank" className="text-muted-foreground hover:text-foreground">
<Youtube className="h-5 w-5" />
</Link>
)}
{siteSettings.social_tiktok && (
<Link href={siteSettings.social_tiktok} target="_blank" className="text-muted-foreground hover:text-foreground">
<FaTiktok className="h-5 w-5" />
</Link>
)}
</div> </div>
</div> </div>
</div> </div>
<div className="container px-4 md:px-6 mt-8 pt-8 border-t text-center text-sm text-muted-foreground"> <div className="container px-4 md:px-6 mt-8 pt-8 border-t text-center text-sm text-muted-foreground">
© 2026 ParaKasa. Tüm hakları saklıdır. © {new Date().getFullYear()} {siteSettings.site_title || "ParaKasa"}. Tüm hakları saklıdır.
</div> </div>
</footer> </footer>
) )

View File

@@ -4,19 +4,33 @@ import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { createClient } from "@/lib/supabase-server" import { createClient } from "@/lib/supabase-server"
import { getSiteContents } from "@/lib/data"
import Image from "next/image"
export async function Navbar() { export async function Navbar() {
const supabase = createClient() const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser() const { data: { user } } = await supabase.auth.getUser()
const siteSettings = await getSiteContents()
return ( return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 items-center"> <div className="container flex h-16 items-center">
<div className="mr-8 hidden md:flex"> <div className="mr-8 hidden md:flex">
<Link href="/" className="mr-6 flex items-center space-x-2"> <Link href="/" className="mr-6 flex items-center space-x-2">
{siteSettings.site_logo ? (
<div className="relative h-10 w-32">
<Image
src={siteSettings.site_logo}
alt={siteSettings.site_title || "ParaKasa"}
fill
className="object-contain object-left"
/>
</div>
) : (
<span className="text-xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-slate-900 to-slate-500 dark:from-slate-100 dark:to-slate-400"> <span className="text-xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-slate-900 to-slate-500 dark:from-slate-100 dark:to-slate-400">
PARAKASA {siteSettings.site_title || "PARAKASA"}
</span> </span>
)}
</Link> </Link>
<nav className="flex items-center space-x-6 text-sm font-medium"> <nav className="flex items-center space-x-6 text-sm font-medium">
<Link <Link
@@ -52,8 +66,19 @@ export async function Navbar() {
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent side="left" className="pr-0"> <SheetContent side="left" className="pr-0">
<Link href="/" className="flex items-center"> <Link href="/" className="flex items-center mb-6">
<span className="font-bold">PARAKASA</span> {siteSettings.site_logo ? (
<div className="relative h-10 w-32">
<Image
src={siteSettings.site_logo}
alt={siteSettings.site_title || "ParaKasa"}
fill
className="object-contain object-left"
/>
</div>
) : (
<span className="font-bold text-xl">{siteSettings.site_title || "PARAKASA"}</span>
)}
</Link> </Link>
<div className="flex flex-col gap-4 mt-8"> <div className="flex flex-col gap-4 mt-8">
<Link href="/products">Ürünler</Link> <Link href="/products">Ürünler</Link>
@@ -63,8 +88,26 @@ export async function Navbar() {
</SheetContent> </SheetContent>
</Sheet> </Sheet>
{/* Mobile Logo (Visible only on mobile) */}
<Link href="/" className="mr-6 flex items-center space-x-2 md:hidden">
{siteSettings.site_logo ? (
<div className="relative h-8 w-24">
<Image
src={siteSettings.site_logo}
alt={siteSettings.site_title || "ParaKasa"}
fill
className="object-contain object-left"
/>
</div>
) : (
<span className="text-lg font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-slate-900 to-slate-500 dark:from-slate-100 dark:to-slate-400">
{siteSettings.site_title || "PARAKASA"}
</span>
)}
</Link>
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end"> <div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
<div className="w-full flex-1 md:w-auto md:flex-none"> <div className="w-full flex-1 md:w-auto md:flex-none hidden md:block">
<div className="relative"> <div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input

View File

@@ -0,0 +1,94 @@
"use client"
import { useState } from "react"
import Image from "next/image"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
interface ProductGalleryProps {
images: string[]
productName: string
}
export function ProductGallery({ images, productName }: ProductGalleryProps) {
const [selectedIndex, setSelectedIndex] = useState(0)
if (!images || images.length === 0) {
return (
<div className="relative aspect-square overflow-hidden rounded-xl border bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400">
Görsel Yok
</div>
)
}
const nextImage = () => {
setSelectedIndex((prev) => (prev + 1) % images.length)
}
const prevImage = () => {
setSelectedIndex((prev) => (prev - 1 + images.length) % images.length)
}
return (
<div className="space-y-4">
{/* Main Image */}
<div className="group relative aspect-square overflow-hidden rounded-xl border bg-slate-100 dark:bg-slate-800">
<Image
src={images[selectedIndex]}
alt={`${productName} - Görsel ${selectedIndex + 1}`}
fill
className="object-cover transition-all duration-300"
priority
/>
{/* Navigation Buttons (Only if multiple images) */}
{images.length > 1 && (
<>
<Button
variant="ghost"
size="icon"
className="absolute left-2 top-1/2 -translate-y-1/2 h-8 w-8 rounded-full bg-white/80 hover:bg-white text-slate-800 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={prevImage}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2 h-8 w-8 rounded-full bg-white/80 hover:bg-white text-slate-800 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={nextImage}
>
<ChevronRight className="h-4 w-4" />
</Button>
</>
)}
</div>
{/* Thumbnails */}
{images.length > 1 && (
<div className="grid grid-cols-4 gap-4">
{images.map((img, idx) => (
<button
key={idx}
onClick={() => setSelectedIndex(idx)}
className={cn(
"relative aspect-square overflow-hidden rounded-lg border bg-slate-100 dark:bg-slate-800 transition-all ring-offset-2",
selectedIndex === idx
? "ring-2 ring-primary"
: "opacity-70 hover:opacity-100"
)}
>
<Image
src={img}
alt={`${productName} thumbnail ${idx + 1}`}
fill
className="object-cover"
/>
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -36,27 +36,21 @@ const buttonVariants = cva(
} }
) )
function Button({ const Button = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button"> &
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean
}) { }>(({ className, variant = "default", size = "default", asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : "button"
return ( return (
<Comp <Comp
data-slot="button" data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props} {...props}
/> />
) )
} })
Button.displayName = "Button"
export { Button, buttonVariants } export { Button, buttonVariants }

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,141 @@
"use client"
import { useState, useRef } from "react"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Loader2, Upload, X } from "lucide-react"
import imageCompression from "browser-image-compression"
import { createClient } from "@/lib/supabase-browser"
import { toast } from "sonner"
import Image from "next/image"
interface ImageUploadProps {
value?: string
onChange: (url: string) => void
onRemove: () => void
disabled?: boolean
}
export function ImageUpload({ value, onChange, onRemove, disabled }: ImageUploadProps) {
const [loading, setLoading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const supabase = createClient()
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setLoading(true)
try {
// 1. Client-side Compression
const options = {
maxSizeMB: 1, // Max 1MB
maxWidthOrHeight: 1920, // Max 1920px width/height
useWebWorker: true,
fileType: "image/webp" // Convert to WebP if possible
}
// Note: browser-image-compression might return Blob instead of File
const compressedFile = await imageCompression(file, options)
// Create a unique file name
// folder structure: sliders/[timestamp]-[random].webp
const fileExt = "webp" // we are forcing conversion to webp usually, or use compressedFile.type
const fileName = `${Date.now()}-${Math.floor(Math.random() * 1000)}.${fileExt}`
const filePath = `uploads/${fileName}`
// 2. Upload to Supabase
const { error: uploadError } = await supabase.storage
.from("images")
.upload(filePath, compressedFile)
if (uploadError) {
throw uploadError
}
// 3. Get Public URL
const { data: { publicUrl } } = supabase.storage
.from("images")
.getPublicUrl(filePath)
onChange(publicUrl)
toast.success("Resim yüklendi ve optimize edildi.")
} catch (error) {
console.error("Upload error:", error)
toast.error("Resim yüklenirken hata oluştu: " + (error as Error).message)
} finally {
setLoading(false)
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
}
return (
<div className="space-y-4 w-full">
<Label>Görsel</Label>
{value ? (
<div className="relative aspect-video w-full max-w-md rounded-lg overflow-hidden border bg-slate-100 dark:bg-slate-800">
<Image
src={value}
alt="Upload preview"
fill
className="object-cover"
/>
<Button
type="button"
onClick={onRemove}
variant="destructive"
size="icon"
className="absolute top-2 right-2 h-8 w-8"
disabled={disabled}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<div
onClick={() => fileInputRef.current?.click()}
className="
border-2 border-dashed border-slate-300 dark:border-slate-700
rounded-lg p-12
flex flex-col items-center justify-center
text-slate-500 dark:text-slate-400
hover:bg-slate-50 dark:hover:bg-slate-900/50
transition cursor-pointer
w-full max-w-md
"
>
<input
type="file"
accept="image/*"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
disabled={loading || disabled}
/>
{loading ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-10 w-10 animate-spin text-primary" />
<p className="text-sm font-medium">Optimize ediliyor ve yükleniyor...</p>
</div>
) : (
<div className="flex flex-col items-center gap-2">
<div className="p-4 bg-slate-100 dark:bg-slate-800 rounded-full">
<Upload className="h-6 w-6" />
</div>
<p className="text-sm font-medium">Resim Yükle</p>
<p className="text-xs text-muted-foreground text-center">
Tıklayın veya sürükleyin.<br />
(Otomatik sıkıştırma: Max 1MB, WebP)
</p>
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,25 @@
import * as React from "react"
import PhoneInput from "react-phone-number-input"
import { cn } from "@/lib/utils"
export interface PhoneInputProps extends React.ComponentProps<typeof PhoneInput> {
className?: string
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const PhoneInputComponent = React.forwardRef<any, PhoneInputProps>(({ className, ...props }, ref) => {
return (
<PhoneInput
ref={ref}
className={cn("flex", className)} // Wrapper class
numberInputProps={{
className: "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
}}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{...(props as any)}
/>
)
})
PhoneInputComponent.displayName = "PhoneInput"
export { PhoneInputComponent as PhoneInput }

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

55
components/ui/tabs.tsx Normal file
View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -13,7 +13,7 @@ export async function submitContactForm(data: ContactFormValues) {
await new Promise((resolve) => setTimeout(resolve, 1000)) await new Promise((resolve) => setTimeout(resolve, 1000))
// In a real app, you would use Resend or Nodemailer here // In a real app, you would use Resend or Nodemailer here
console.log("Contact Form Submitted:", result.data)
return { success: true, message: "Mesajınız başarıyla gönderildi." } return { success: true, message: "Mesajınız başarıyla gönderildi." }
} }

88
lib/customers/actions.ts Normal file
View File

@@ -0,0 +1,88 @@
'use server'
import { createClient } from "@/lib/supabase-server"
import { Customer, CustomerInsert, CustomerUpdate } from "@/types/customer"
import { revalidatePath } from "next/cache"
// Get all customers
export async function getCustomers() {
const supabase = createClient()
const { data, error } = await supabase
.from('customers')
.select('*')
.order('created_at', { ascending: false })
if (error) {
console.error('Error fetching customers:', error)
return { success: false, error: error.message }
}
return { success: true, data: data as Customer[] }
}
// Get customer by ID
export async function getCustomerById(id: number) {
const supabase = createClient()
const { data, error } = await supabase
.from('customers')
.select('*')
.eq('id', id)
.single()
if (error) {
return { success: false, error: error.message }
}
return { success: true, data: data as Customer }
}
// Add new customer
export async function addCustomer(customer: CustomerInsert) {
const supabase = createClient()
const { data, error } = await supabase
.from('customers')
.insert(customer)
.select()
.single()
if (error) {
return { success: false, error: error.message }
}
revalidatePath('/dashboard/customers')
return { success: true, data: data as Customer }
}
// Update existing customer
export async function updateCustomer(id: number, customer: CustomerUpdate) {
const supabase = createClient()
const { data, error } = await supabase
.from('customers')
.update({ ...customer, updated_at: new Date().toISOString() })
.eq('id', id)
.select()
.single()
if (error) {
return { success: false, error: error.message }
}
revalidatePath('/dashboard/customers')
return { success: true, data: data as Customer }
}
// Delete customer
export async function deleteCustomer(id: number) {
const supabase = createClient()
const { error } = await supabase
.from('customers')
.delete()
.eq('id', id)
if (error) {
return { success: false, error: error.message }
}
revalidatePath('/dashboard/customers')
return { success: true }
}

31
lib/data.ts Normal file
View File

@@ -0,0 +1,31 @@
import { cache } from 'react'
import { createClient } from '@/lib/supabase-server'
export const getProfile = cache(async (userId: string) => {
const supabase = createClient()
const { data } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single()
return data
})
export const getSiteContents = cache(async () => {
const supabase = createClient()
const { data } = await supabase
.from('site_contents')
.select('*')
// Convert to a simpler key-value map for easier usage in components
const contentMap: Record<string, string> = {}
if (data) {
data.forEach((item: { key: string; value: string }) => {
contentMap[item.key] = item.value
})
}
return contentMap
})

View File

@@ -1,8 +0,0 @@
import { createClient } from "@/lib/supabase-server"
import { cache } from "react"
export const getSiteSettings = cache(async () => {
const supabase = createClient()
const { data } = await supabase.from('site_settings').select('*').single()
return data
})

203
lib/sms/actions.ts Normal file
View File

@@ -0,0 +1,203 @@
"use server"
import { createClient } from "@/lib/supabase-server"
import { createClient as createSupabaseClient } from "@supabase/supabase-js"
import { revalidatePath } from "next/cache"
import { NetGsmService } from "./netgsm"
// Admin client for privileged operations (accessing sms_settings)
const supabaseAdmin = createSupabaseClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
auth: {
autoRefreshToken: false,
persistSession: false
}
}
)
async function assertAdmin() {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error("Oturum açmanız gerekiyor.")
const { data: profile } = await supabase.from('profiles').select('role').eq('id', user.id).single()
if (profile?.role !== 'admin') throw new Error("Yetkisiz işlem.")
return user
}
export async function getSmsSettings() {
try {
await assertAdmin()
const { data, error } = await supabaseAdmin
.from('sms_settings')
.select('*')
.single()
if (error && error.code !== 'PGRST116') { // PGRST116 is 'not found', which is fine initially
throw error
}
return { data }
} catch (error) {
return { error: (error as Error).message }
}
}
export async function updateSmsSettings(data: {
username: string
password?: string // Optional if not changing
header: string
}) {
try {
await assertAdmin()
// Check if exists
const { data: existing } = await supabaseAdmin.from('sms_settings').select('id').single()
const updates: {
username: string
header: string
updated_at: string
password?: string
} = {
username: data.username,
header: data.header,
updated_at: new Date().toISOString()
}
// Only update password if provided
if (data.password && data.password.trim() !== '') {
updates.password = data.password
}
if (existing) {
const { error } = await supabaseAdmin
.from('sms_settings')
.update(updates)
.eq('id', existing.id)
if (error) throw error
} else {
// First time setup, password is mandatory if not exists, but we can't easily check 'locally'
// We assume if new, password must be in updates.
if (!data.password) throw new Error("Yeni kurulum için şifre gereklidir.")
const { error } = await supabaseAdmin
.from('sms_settings')
.insert({ ...updates, password: data.password })
if (error) throw error
}
revalidatePath("/dashboard/settings")
return { success: true }
} catch (error) {
return { error: (error as Error).message }
}
}
export async function sendTestSms(phone: string) {
try {
await assertAdmin()
// Fetch credentials
const { data: settings } = await supabaseAdmin.from('sms_settings').select('*').single()
if (!settings) throw new Error("SMS ayarları yapılmamış.")
const mobileService = new NetGsmService({
username: settings.username,
password: settings.password,
header: settings.header,
apiUrl: settings.api_url
})
const result = await mobileService.sendSms(phone, "ParaKasa Test SMS: Entegrasyon basarili.")
// Log the result
await supabaseAdmin.from('sms_logs').insert({
phone,
message: "ParaKasa Test SMS: Entegrasyon basarili.",
status: result.success ? 'success' : 'error',
response_code: result.code || result.error
})
if (!result.success) {
throw new Error(result.error || "SMS gönderilemedi.")
}
return { success: true, jobId: result.jobId }
} catch (error) {
return { error: (error as Error).message }
}
}
export async function sendBulkSms(phones: string[], message: string) {
try {
await assertAdmin()
// Fetch credentials
const { data: settings } = await supabaseAdmin.from('sms_settings').select('*').single()
if (!settings) throw new Error("SMS ayarları yapılmamış.")
const mobileService = new NetGsmService({
username: settings.username,
password: settings.password,
header: settings.header,
apiUrl: settings.api_url
})
// Remove duplicates and empty
const uniquePhones = Array.from(new Set(phones.filter(p => p && p.trim() !== '')))
const results = []
for (const phone of uniquePhones) {
const result = await mobileService.sendSms(phone, message)
// Log result
await supabaseAdmin.from('sms_logs').insert({
phone,
message,
status: result.success ? 'success' : 'error',
response_code: result.code || result.error
})
results.push({ phone, ...result })
}
const successCount = results.filter(r => r.success).length
const total = uniquePhones.length
revalidatePath("/dashboard/sms")
return {
success: true,
message: `${total} kişiden ${successCount} kişiye başarıyla gönderildi.`,
details: results
}
} catch (error) {
return { error: (error as Error).message }
}
}
export async function getSmsLogs(limit: number = 50) {
try {
await assertAdmin()
const { data, error } = await supabaseAdmin
.from('sms_logs')
.select('*')
.order('created_at', { ascending: false })
.limit(limit)
if (error) throw error
return { success: true, data }
} catch (error) {
return { error: (error as Error).message }
}
}

88
lib/sms/netgsm.ts Normal file
View File

@@ -0,0 +1,88 @@
export interface NetGsmConfig {
username?: string;
password?: string;
header?: string;
apiUrl?: string;
}
export interface SmsResult {
success: boolean;
jobId?: string;
error?: string;
code?: string;
}
export class NetGsmService {
private config: NetGsmConfig;
constructor(config: NetGsmConfig) {
this.config = config;
}
/**
* Send SMS using NetGSM GET API
* Refer: https://www.netgsm.com.tr/dokuman/#http-get-servisi
*/
async sendSms(phone: string, message: string): Promise<SmsResult> {
if (!this.config.username || !this.config.password || !this.config.header) {
return { success: false, error: "NetGSM konfigürasyonu eksik." };
}
// Clean phone number (remove spaces, parentheses, etc)
// NetGSM expects 905xxxxxxxxx or just 5xxxxxxxxx, we'll ensure format
let cleanPhone = phone.replace(/\D/g, '');
if (cleanPhone.startsWith('90')) {
cleanPhone = cleanPhone.substring(0); // keep it
} else if (cleanPhone.startsWith('0')) {
cleanPhone = '9' + cleanPhone;
} else if (cleanPhone.length === 10) {
cleanPhone = '90' + cleanPhone;
}
try {
// Encode parameters
const params = new URLSearchParams({
usercode: this.config.username,
password: this.config.password,
gsmno: cleanPhone,
message: message,
msgheader: this.config.header,
dil: 'TR' // Turkish characters support
});
const url = `${this.config.apiUrl || 'https://api.netgsm.com.tr/sms/send/get'}?${params.toString()}`;
const response = await fetch(url);
const textResponse = await response.text();
// NetGSM returns a code (e.g. 00 123456789) or error code (e.g. 20)
// Codes starting with 00, 01, 02 indicate success
const code = textResponse.split(' ')[0];
if (['00', '01', '02'].includes(code)) {
return { success: true, jobId: textResponse.split(' ')[1] || code, code };
} else {
const errorMap: Record<string, string> = {
'20': 'Mesaj metni ya da karakter sınırını (1.000) aştı veya mesaj boş.',
'30': 'Geçersiz kullanıcı adı , şifre veya kullanıcınızın API erişim izni yok.',
'40': 'Gönderici adı (Başlık) sistemde tanımlı değil.',
'70': 'Hatalı sorgu.',
'50': 'Kendi numaranıza veya Rehberden SMS gönderiyorsanız; Abone kendi numarasını veya rehberindeki bir numarayı gönderici kimliği (MsgHeader) olarak kullanamaz.',
'51': 'Aboneliğinizin süresi dolmuş.',
'52': 'Aboneliğiniz bulunmamaktadır.',
'60': 'Bakiyeniz yetersiz.',
'71': 'Gönderim yapmak istediğiniz gsm numarası/numaraları hatalı.'
};
return {
success: false,
code,
error: errorMap[code] || `Bilinmeyen hata kodu: ${code} - Yanıt: ${textResponse}`
};
}
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
}

57
lib/sms/templates.ts Normal file
View File

@@ -0,0 +1,57 @@
"use server"
import { createClient } from "@/lib/supabase-server"
import { revalidatePath } from "next/cache"
export interface SmsTemplate {
id: string
title: string
message: string
created_at: string
}
export async function getTemplates() {
try {
const supabase = createClient()
const { data, error } = await supabase
.from('sms_templates')
.select('*')
.order('created_at', { ascending: false })
if (error) throw error
return { success: true, data: data as SmsTemplate[] }
} catch (error) {
return { success: false, error: (error as Error).message }
}
}
export async function createTemplate(title: string, message: string) {
try {
const supabase = createClient()
const { error } = await supabase
.from('sms_templates')
.insert({ title, message })
if (error) throw error
revalidatePath('/dashboard/sms')
return { success: true }
} catch (error) {
return { success: false, error: (error as Error).message }
}
}
export async function deleteTemplate(id: string) {
try {
const supabase = createClient()
const { error } = await supabase
.from('sms_templates')
.delete()
.eq('id', id)
if (error) throw error
revalidatePath('/dashboard/sms')
return { success: true }
} catch (error) {
return { success: false, error: (error as Error).message }
}
}

View File

@@ -0,0 +1,124 @@
"use server"
import { createClient } from "@/lib/supabase-server"
import { createClient as createSupabaseClient } from "@supabase/supabase-js"
import { cookies } from "next/headers"
import { NetGsmService } from "./netgsm"
// We will reuse sendTestSms logic or create a specific one. sendTestSms uses Netgsm Service.
// Better to export a generic 'sendSms' from lib/sms/actions.ts or just invoke the service directly.
// lib/sms/actions.ts has `sendBulkSms` and `sendTestSms`. I should probably expose a generic `sendSms` there.
// Admin client for Auth Codes table access
const supabaseAdmin = createSupabaseClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { autoRefreshToken: false, persistSession: false } }
)
export async function sendVerificationCode() {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: "Kullanıcı bulunamadı." }
// 1. Get user phone
const { data: profile } = await supabaseAdmin
.from('profiles')
.select('phone')
.eq('id', user.id)
.single()
if (!profile?.phone) {
return { error: "Profilinizde telefon numarası tanımlı değil. Lütfen yöneticinizle iletişime geçin." }
}
// 2. Generate Code
const code = Math.floor(100000 + Math.random() * 900000).toString() // 6 digit
const expiresAt = new Date(Date.now() + 5 * 60 * 1000) // 5 mins
// 3. Store in DB
// First, delete old codes for this email/user
await supabaseAdmin.from('auth_codes').delete().eq('email', user.email!)
const { error: dbError } = await supabaseAdmin.from('auth_codes').insert({
email: user.email!,
code,
expires_at: expiresAt.toISOString()
})
if (dbError) {
console.error("Auth code db error:", dbError)
return { error: "Doğrulama kodu oluşturulamadı." }
}
// 4. Send SMS
// We import the logic from Netgsm service wrapper
// Since we don't have a direct 'sendSms' export in existing actions that accepts phone/message directly without admin assertion (which we have here via admin client, but the helper function `sendTestSms` does its own checks).
// I will use a direct call to the generic `NetgsmService` logic if I can, or modify `lib/sms/actions.ts` to export it.
// To avoid modifying too many files, I'll instantiate NetgsmService here if I can import it, or just use `sendBulkSms` with one number?
// `sendBulkSms` asserts admin user. But here the calling user IS logged in (but might not be admin?).
// Actually, `sendVerificationCode` is called by the logging-in user (who might be just 'user' role).
// `lib/sms/actions.ts` -> `assertAdmin()` checks if current user is admin.
// So if a normal user logs in, `sendBulkSms` will fail.
// WE NEED A SYSTEM LEVEL SEND FUNCTION.
// I will read credentials directly using Admin Client here.
const { data: settings } = await supabaseAdmin.from('sms_settings').select('*').single()
if (!settings) return { error: "SMS servisi yapılandırılmamış." }
// Import the class dynamically or duplicate usage?
// The class is in `./netgsm.ts` (based on actions.ts imports).
// Let's import { NetGsmService } from "./netgsm"
// NetGsmService imported at top
const mobileService = new NetGsmService({
username: settings.username,
password: settings.password,
header: settings.header,
apiUrl: settings.api_url
})
const smsResult = await mobileService.sendSms(profile.phone, `Giris Dogrulama Kodunuz: ${code}`)
if (!smsResult.success) {
console.error("SMS Send Error:", smsResult)
return { error: "SMS gönderilemedi. Lütfen daha sonra tekrar deneyin." }
}
return { success: true, phone: profile.phone.slice(-4) } // Return last 4 digits
}
export async function verifyCode(code: string) {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: "Oturum bulunamadı." }
// Check code
const { data: record, error } = await supabaseAdmin
.from('auth_codes')
.select('*')
.eq('email', user.email!)
.eq('code', code)
.gt('expires_at', new Date().toISOString())
.single()
if (error || !record) {
return { error: "Geçersiz veya süresi dolmuş kod." }
}
// Success: Set Cookie
cookies().set('parakasa_2fa_verified', 'true', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 // 24 hours
})
// Delete used code
await supabaseAdmin.from('auth_codes').delete().eq('id', record.id)
return { success: true }
}

BIN
lint-results.txt Normal file

Binary file not shown.

BIN
lint.log Normal file

Binary file not shown.

4
lint_errors.txt Normal file
View File

@@ -0,0 +1,4 @@
> parakasa@0.1.0 lint
> next lint

BIN
lint_output.txt Normal file

Binary file not shown.

BIN
lint_output_2.txt Normal file

Binary file not shown.

BIN
lint_output_3.txt Normal file

Binary file not shown.

BIN
lint_report.txt Normal file

Binary file not shown.

5
lint_results.txt Normal file
View File

@@ -0,0 +1,5 @@
> parakasa@0.1.0 lint
> next lint
✔ No ESLint warnings or errors

View File

@@ -1,8 +0,0 @@
-- Insert profile for the existing user and make them admin
insert into public.profiles (id, role, full_name)
select id, 'admin', 'Sistem Yöneticisi'
from auth.users
where email = 'kenankaraerr@hotmail.com'
on conflict (id) do update
set role = 'admin';

View File

@@ -38,11 +38,19 @@ export async function middleware(request: NextRequest) {
} = await supabase.auth.getUser(); } = await supabase.auth.getUser();
// Protected routes // Protected routes
if (!user && request.nextUrl.pathname.startsWith("/dashboard")) { if (request.nextUrl.pathname.startsWith("/dashboard")) {
if (!user) {
return NextResponse.redirect(new URL("/login", request.url)); return NextResponse.redirect(new URL("/login", request.url));
} }
// Redirect to dashboard if logged in and trying to access auth pages // 2FA Check
const isVerified = request.cookies.get('parakasa_2fa_verified')?.value === 'true'
if (!isVerified) {
return NextResponse.redirect(new URL("/verify-2fa", request.url));
}
}
// Redirect to dashboard (or verify) if logged in
if (user && (request.nextUrl.pathname.startsWith("/login") || request.nextUrl.pathname.startsWith("/signup"))) { if (user && (request.nextUrl.pathname.startsWith("/login") || request.nextUrl.pathname.startsWith("/signup"))) {
return NextResponse.redirect(new URL("/dashboard", request.url)); return NextResponse.redirect(new URL("/dashboard", request.url));
} }

View File

@@ -0,0 +1,5 @@
-- Add product_code to products table
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS product_code TEXT;
-- Create an index for faster lookups if needed (optional but good practice)
-- CREATE INDEX IF NOT EXISTS idx_products_product_code ON public.products(product_code);

View File

@@ -0,0 +1,14 @@
-- SMS TEMPLATES
CREATE TABLE IF NOT EXISTS public.sms_templates (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
title TEXT NOT NULL,
message TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- RLS Policies for SMS Templates
ALTER TABLE public.sms_templates ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Admins can full access sms templates" ON public.sms_templates USING (
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
);

345
migrations/schema_full.sql Normal file
View File

@@ -0,0 +1,345 @@
-- ParaKasa Consolidated Database Schema
-- Generated on 2026-01-29
-- This file contains the entire database structure, RLS policies, and storage setup.
-- 1. Enable Extensions
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- 2. Tables
-- PROFILES
CREATE TABLE IF NOT EXISTS public.profiles (
id UUID REFERENCES auth.users ON DELETE CASCADE PRIMARY KEY,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')),
full_name TEXT,
phone TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- SITE SETTINGS
CREATE TABLE IF NOT EXISTS public.site_settings (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
site_title TEXT NOT NULL DEFAULT 'ParaKasa',
site_description TEXT,
contact_email TEXT,
contact_phone TEXT,
logo_url TEXT,
currency TEXT DEFAULT 'TRY',
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- SITE CONTENTS (Dynamic CMS)
CREATE TABLE IF NOT EXISTS public.site_contents (
key TEXT PRIMARY KEY,
value TEXT,
type TEXT CHECK (type IN ('text', 'image_url', 'html', 'long_text', 'json')),
section TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- CATEGORIES
CREATE TABLE IF NOT EXISTS public.categories (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT,
image_url TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- PRODUCTS
CREATE TABLE IF NOT EXISTS public.products (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
name TEXT NOT NULL,
category TEXT NOT NULL, -- Legacy text field, kept for compatibility
category_id UUID REFERENCES public.categories(id) ON DELETE SET NULL, -- Foreign key relation
description TEXT,
image_url TEXT,
price DECIMAL(10,2),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_products_category_id ON public.products(category_id);
-- PRODUCT IMAGES (Multi-image support)
CREATE TABLE IF NOT EXISTS public.product_images (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
product_id BIGINT REFERENCES public.products(id) ON DELETE CASCADE NOT NULL,
image_url TEXT NOT NULL,
display_order INT DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_product_images_product_id ON public.product_images(product_id);
-- SLIDERS
CREATE TABLE IF NOT EXISTS public.sliders (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
image_url TEXT NOT NULL,
link TEXT,
"order" INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- CUSTOMERS
CREATE TABLE IF NOT EXISTS public.customers (
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
full_name TEXT NOT NULL,
email TEXT,
phone TEXT,
address TEXT,
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- SMS SETTINGS
CREATE TABLE IF NOT EXISTS public.sms_settings (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
provider TEXT DEFAULT 'netgsm',
api_url TEXT DEFAULT 'https://api.netgsm.com.tr/sms/send/get',
username TEXT,
password TEXT,
header TEXT,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now())
);
-- SMS LOGS
CREATE TABLE IF NOT EXISTS public.sms_logs (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
phone TEXT NOT NULL,
message TEXT NOT NULL,
status TEXT, -- 'success' or 'error'
response_code TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now())
);
-- AUTH CODES (2FA / Verification)
CREATE TABLE IF NOT EXISTS public.auth_codes (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
email TEXT NOT NULL,
code TEXT NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_auth_codes_email ON public.auth_codes(email);
-- 3. Row Level Security (RLS) & Policies
-- Enable RLS
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.site_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.site_contents ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.categories ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.products ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.product_images ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.sliders ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.sms_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.sms_logs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.auth_codes ENABLE ROW LEVEL SECURITY;
-- Helper function for admin check (optional, but cleaner if used commonly)
-- For this script, we'll use the EXISTS subquery pattern directly to ensure portability.
-- PROFILES POLICIES
CREATE POLICY "Public profiles are viewable by everyone."
ON public.profiles FOR SELECT USING ( true );
CREATE POLICY "Users can insert their own profile."
ON public.profiles FOR INSERT WITH CHECK ( auth.uid() = id );
CREATE POLICY "Users can update own profile."
ON public.profiles FOR UPDATE USING ( auth.uid() = id );
-- SITE SETTINGS POLICIES
CREATE POLICY "Site settings are viewable by everyone."
ON public.site_settings FOR SELECT USING ( true );
CREATE POLICY "Admins can update site settings."
ON public.site_settings FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
);
-- SITE CONTENTS POLICIES
CREATE POLICY "Public read access"
ON public.site_contents FOR SELECT USING (true);
CREATE POLICY "Admins can insert site contents"
ON public.site_contents FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
);
CREATE POLICY "Admins can update site contents"
ON public.site_contents FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
);
-- CATEGORIES POLICIES
CREATE POLICY "Public categories are viewable by everyone."
ON public.categories FOR SELECT USING ( true );
CREATE POLICY "Admins can insert categories."
ON public.categories FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
);
CREATE POLICY "Admins can update categories."
ON public.categories FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
);
CREATE POLICY "Admins can delete categories."
ON public.categories FOR DELETE USING (
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
);
-- PRODUCTS POLICIES
CREATE POLICY "Public products are viewable by everyone."
ON public.products FOR SELECT USING ( true );
CREATE POLICY "Admins can insert products"
ON public.products FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
);
CREATE POLICY "Admins can update products"
ON public.products FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
);
CREATE POLICY "Admins can delete products"
ON public.products FOR DELETE USING (
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
);
-- PRODUCT IMAGES POLICIES (Inherit from products basically, or admin only)
CREATE POLICY "Public product images are viewable."
ON public.product_images FOR SELECT USING ( true );
CREATE POLICY "Admins can insert product images"
ON public.product_images FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
);
CREATE POLICY "Admins can delete product images"
ON public.product_images FOR DELETE USING (
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
);
-- SLIDERS POLICIES
CREATE POLICY "Public sliders are viewable by everyone."
ON public.sliders FOR SELECT USING ( true );
CREATE POLICY "Admins can insert sliders."
ON public.sliders FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
);
CREATE POLICY "Admins can update sliders."
ON public.sliders FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
);
CREATE POLICY "Admins can delete sliders."
ON public.sliders FOR DELETE USING (
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
);
-- CUSTOMERS POLICIES
CREATE POLICY "Admins can view customers"
ON public.customers FOR SELECT USING (
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
);
CREATE POLICY "Admins can insert customers"
ON public.customers FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
);
CREATE POLICY "Admins can update customers"
ON public.customers FOR UPDATE USING (
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
);
CREATE POLICY "Admins can delete customers"
ON public.customers FOR DELETE USING (
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
);
-- SMS SETTINGS/LOGS POLICIES
CREATE POLICY "Admins can full access sms" ON public.sms_settings USING (
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
);
CREATE POLICY "Admins can full access sms logs" ON public.sms_logs USING (
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
);
-- 4. Initial Data
-- Site Settings Default
INSERT INTO public.site_settings (site_title, contact_email)
SELECT 'ParaKasa', 'info@parakasa.com'
WHERE NOT EXISTS (SELECT 1 FROM public.site_settings);
-- Site Contents Defaults
INSERT INTO public.site_contents (key, value, type, section) VALUES
('site_title', 'ParaKasa', 'text', 'general'),
('site_description', 'ParaKasa Yönetim Paneli', 'long_text', 'general'),
('site_logo', '', 'image_url', 'general'),
('contact_phone', '', 'text', 'contact'),
('contact_email', '', 'text', 'contact'),
('contact_address', '', 'long_text', 'contact'),
('social_instagram', '', 'text', 'contact'),
('social_youtube', '', 'text', 'contact'),
('social_tiktok', '', 'text', 'contact'),
('contact_map_embed', '', 'html', 'contact')
ON CONFLICT (key) DO NOTHING;
-- 5. Storage Buckets & Policies
-- Buckets
INSERT INTO storage.buckets (id, name, public) VALUES ('products', 'products', true) ON CONFLICT (id) DO NOTHING;
INSERT INTO storage.buckets (id, name, public) VALUES ('categories', 'categories', true) ON CONFLICT (id) DO NOTHING;
INSERT INTO storage.buckets (id, name, public) VALUES ('sliders', 'sliders', true) ON CONFLICT (id) DO NOTHING;
-- Drop existing policies to avoid duplicates if re-running
DROP POLICY IF EXISTS "Public Access Products" ON storage.objects;
DROP POLICY IF EXISTS "Auth Upload Products" ON storage.objects;
DROP POLICY IF EXISTS "Auth Update Products" ON storage.objects;
DROP POLICY IF EXISTS "Auth Delete Products" ON storage.objects;
-- Products Policies
CREATE POLICY "Public Access Products" ON storage.objects FOR SELECT USING ( bucket_id = 'products' );
CREATE POLICY "Auth Upload Products" ON storage.objects FOR INSERT WITH CHECK ( bucket_id = 'products' AND auth.role() = 'authenticated' );
CREATE POLICY "Auth Update Products" ON storage.objects FOR UPDATE USING ( bucket_id = 'products' AND auth.role() = 'authenticated' );
CREATE POLICY "Auth Delete Products" ON storage.objects FOR DELETE USING ( bucket_id = 'products' AND auth.role() = 'authenticated' );
-- Categories Policies
DROP POLICY IF EXISTS "Public Access Categories" ON storage.objects;
DROP POLICY IF EXISTS "Auth Upload Categories" ON storage.objects;
DROP POLICY IF EXISTS "Auth Update Categories" ON storage.objects;
DROP POLICY IF EXISTS "Auth Delete Categories" ON storage.objects;
CREATE POLICY "Public Access Categories" ON storage.objects FOR SELECT USING ( bucket_id = 'categories' );
CREATE POLICY "Auth Upload Categories" ON storage.objects FOR INSERT WITH CHECK ( bucket_id = 'categories' AND auth.role() = 'authenticated' );
CREATE POLICY "Auth Update Categories" ON storage.objects FOR UPDATE USING ( bucket_id = 'categories' AND auth.role() = 'authenticated' );
CREATE POLICY "Auth Delete Categories" ON storage.objects FOR DELETE USING ( bucket_id = 'categories' AND auth.role() = 'authenticated' );
-- Sliders Policies
DROP POLICY IF EXISTS "Public Access Sliders" ON storage.objects;
DROP POLICY IF EXISTS "Auth Upload Sliders" ON storage.objects;
DROP POLICY IF EXISTS "Auth Update Sliders" ON storage.objects;
DROP POLICY IF EXISTS "Auth Delete Sliders" ON storage.objects;
CREATE POLICY "Public Access Sliders" ON storage.objects FOR SELECT USING ( bucket_id = 'sliders' );
CREATE POLICY "Auth Upload Sliders" ON storage.objects FOR INSERT WITH CHECK ( bucket_id = 'sliders' AND auth.role() = 'authenticated' );
CREATE POLICY "Auth Update Sliders" ON storage.objects FOR UPDATE USING ( bucket_id = 'sliders' AND auth.role() = 'authenticated' );
CREATE POLICY "Auth Delete Sliders" ON storage.objects FOR DELETE USING ( bucket_id = 'sliders' AND auth.role() = 'authenticated' );

View File

@@ -1,6 +1,18 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**.supabase.co',
},
{
protocol: 'https',
hostname: 'api.unsalcelikparakasalari.com',
},
],
},
webpack: (config) => { webpack: (config) => {
// Suppress cache serialization warnings // Suppress cache serialization warnings
config.infrastructureLogging = { config.infrastructureLogging = {

8
nixpacks.toml Normal file
View File

@@ -0,0 +1,8 @@
[phases.setup]
nixPkgs = ["nodejs"]
[phases.install]
cmds = ["npm ci"]
[phases.build]
cmds = ["npm run build"]

14
notlar.txt Normal file
View File

@@ -0,0 +1,14 @@
// Rehber api bilgisi
Telefon rehberi erişimi için planı hazırladım.
Bu özellik Contact Picker API kullanılarak yapılacak. Önemli Not: Bu özellik tarayıcı desteğine bağlıdır. Genellikle Android telefonlarda ve Chrome tarayıcıda sorunsuz çalışır. iPhone veya masaüstü bilgisayarlarda bu özellik tarayıcı tarafından desteklenmeyebilir. Desteklenmeyen cihazlarda bu butonu gizleyeceğiz.
Onaylıyorsanız kodlamaya geçiyorum.
// resim görüntülemek için düzgün açıklama
Tıklanabilir Telefon: İletişim sayfasındaki telefon numarasını link haline getireceğim. Mobilde tıkladığınızda direkt arama ekranıılacak.
Akıllı Galeri: Ürün detay sayfasına "İnteraktif Galeri" ekleyeceğim.
Küçük resimlere tıklayınca büyük resim anında değişecek.
Büyük resmin üzerinde Sağ/Sol ok tuşları olacak, böylece resimler arasında kolayca gezebileceksiniz.

526
package-lock.json generated
View File

@@ -10,16 +10,20 @@
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@supabase/ssr": "^0.8.0", "@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.89.0", "@supabase/supabase-js": "^2.89.0",
"browser-image-compression": "^2.0.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@@ -30,20 +34,26 @@
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.70.0", "react-hook-form": "^7.70.0",
"react-icons": "^5.5.0",
"react-phone-number-input": "^3.4.14",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"zod": "^4.3.5" "zod": "^4.3.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.16.0",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"dotenv": "^17.2.3",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "14.2.16", "eslint-config-next": "14.2.16",
"pg": "^8.17.2",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"ts-node": "^10.9.2",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5" "typescript": "^5"
} }
@@ -61,6 +71,30 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.7.1", "version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
@@ -664,6 +698,36 @@
} }
} }
}, },
"node_modules/@radix-ui/react-checkbox": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
"integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": { "node_modules/@radix-ui/react-collection": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
@@ -1213,6 +1277,37 @@
} }
} }
}, },
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
"integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": { "node_modules/@radix-ui/react-select": {
"version": "2.2.6", "version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
@@ -1367,6 +1462,36 @@
} }
} }
}, },
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": { "node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@@ -1678,6 +1803,34 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.1", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -1705,6 +1858,18 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/pg": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz",
"integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/phoenix": { "node_modules/@types/phoenix": {
"version": "1.6.7", "version": "1.6.7",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz", "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
@@ -2316,6 +2481,19 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/acorn-walk": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -2720,6 +2898,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/browser-image-compression": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz",
"integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==",
"license": "MIT",
"dependencies": {
"uzip": "0.20201231.0"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.1", "version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@@ -2922,6 +3109,12 @@
"url": "https://polar.sh/cva" "url": "https://polar.sh/cva"
} }
}, },
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/client-only": { "node_modules/client-only": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -2987,6 +3180,19 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/country-flag-icons": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.6.4.tgz",
"integrity": "sha512-Z3Zi419FI889tlElMsVhCIS5eRkiLDWixr576J5DPiTe5RGxpbRi+enMpHdYVp5iK5WFjr8P/RgyIFAGhFsiFg==",
"license": "MIT"
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3167,6 +3373,16 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/diff": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dlv": { "node_modules/dlv": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -3187,6 +3403,19 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -4504,6 +4733,27 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/input-format": {
"version": "0.3.14",
"resolved": "https://registry.npmjs.org/input-format/-/input-format-0.3.14.tgz",
"integrity": "sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">=18.1.0",
"react-dom": ">=18.1.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/internal-slot": { "node_modules/internal-slot": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -5128,6 +5378,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/libphonenumber-js": {
"version": "1.12.33",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.33.tgz",
"integrity": "sha512-r9kw4OA6oDO4dPXkOrXTkArQAafIKAU71hChInV4FxZ69dxCfbwQGDPzqR5/vea94wU705/3AZroEbSoeVWrQw==",
"license": "MIT"
},
"node_modules/lilconfig": { "node_modules/lilconfig": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -5199,6 +5455,13 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0"
} }
}, },
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"license": "ISC"
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -5436,7 +5699,6 @@
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -5710,6 +5972,103 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/pg": {
"version": "8.17.2",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.17.2.tgz",
"integrity": "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==",
"dev": true,
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.10.1",
"pg-pool": "^3.11.0",
"pg-protocol": "^1.11.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.10.1.tgz",
"integrity": "sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw==",
"dev": true,
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
"dev": true,
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -5922,6 +6281,49 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5936,7 +6338,6 @@
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
@@ -6016,13 +6417,38 @@
"react": "^16.8.0 || ^17 || ^18 || ^19" "react": "^16.8.0 || ^17 || ^18 || ^19"
} }
}, },
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-phone-number-input": {
"version": "3.4.14",
"resolved": "https://registry.npmjs.org/react-phone-number-input/-/react-phone-number-input-3.4.14.tgz",
"integrity": "sha512-T9MziNuvthzv6+JAhKD71ab/jVXW5U20nQZRBJd6+q+ujmkC+/ISOf2GYo8pIi4VGjdIYRIHDftMAYn3WKZT3w==",
"license": "MIT",
"dependencies": {
"classnames": "^2.5.1",
"country-flag-icons": "^1.5.17",
"input-format": "^0.3.14",
"libphonenumber-js": "^1.12.27",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/react-remove-scroll": { "node_modules/react-remove-scroll": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -6531,6 +6957,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/stable-hash": { "node_modules/stable-hash": {
"version": "0.0.5", "version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -7034,6 +7470,57 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/ts-node/node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
"node_modules/tsconfig-paths": { "node_modules/tsconfig-paths": {
"version": "3.15.0", "version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -7341,6 +7828,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/uzip": {
"version": "0.20201231.0",
"resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz",
"integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==",
"license": "MIT"
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT"
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -7585,6 +8085,26 @@
} }
} }
}, },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -6,21 +6,28 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "next lint"
},
"engines": {
"node": "20.x"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@supabase/ssr": "^0.8.0", "@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.89.0", "@supabase/supabase-js": "^2.89.0",
"browser-image-compression": "^2.0.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@@ -31,20 +38,26 @@
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.70.0", "react-hook-form": "^7.70.0",
"react-icons": "^5.5.0",
"react-phone-number-input": "^3.4.14",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"zod": "^4.3.5" "zod": "^4.3.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
"@types/pg": "^8.16.0",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"dotenv": "^17.2.3",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "14.2.16", "eslint-config-next": "14.2.16",
"pg": "^8.17.2",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"ts-node": "^10.9.2",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5" "typescript": "^5"
} }

BIN
public/avatars/01.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

BIN
public/fonts/Inter-Bold.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/fonts/Inter-Thin.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More