Compare commits

1 Commits
main ... kenan

Author SHA1 Message Date
b7e809d194 Merge pull request 'main' (#3) from main into kenan
Reviewed-on: #3
2026-01-10 09:54:58 +00:00
118 changed files with 894 additions and 5613 deletions

View File

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

View File

@@ -17,16 +17,7 @@
---
## Mevcut Durum (Tamamlananlar)
- [x] Kullanıcı Yönetimi (Admin Ekle/Sil/Düzenle + Telefon).
- [x] Kullanıcı Yönetimi (Admin Ekle/Sil).
- [x] Temel Site Ayarları (Başlık, İletişim).
- [x] Ürün Yönetimi (Temel CRUD).
- [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

@@ -1,45 +0,0 @@
'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

@@ -1,61 +0,0 @@
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

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

View File

@@ -3,97 +3,49 @@
import { createClient } from "@/lib/supabase-server"
import { revalidatePath } from "next/cache"
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) {
export async function createProduct(data: any) {
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 {
// 1. Create Product
const { data: product, error } = await supabase.from("products").insert({
const { error } = await supabase.from("products").insert({
name: data.name,
category: data.category,
description: data.description,
price: data.price,
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()
image_url: data.image_url,
})
if (error) throw error
// 2. Insert Images (if any)
if (data.images && data.images.length > 0) {
const imageInserts = data.images.map((url, index) => ({
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
}
}
revalidatePath("/dashboard/products")
return { success: true }
} catch (error) {
return { success: false, error: (error as Error).message }
} catch (error: any) {
return { success: false, error: error.message }
}
}
export async function updateProduct(id: number, data: ProductData) {
export async function updateProduct(id: number, data: any) {
const supabase = createClient()
try {
// 1. Update Product
const { error } = await supabase.from("products").update({
name: data.name,
category: data.category,
description: data.description,
price: data.price,
image_url: data.image_url,
is_active: data.is_active,
product_code: data.product_code
}).eq("id", id)
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/${id}`)
return { success: true }
} catch (error) {
return { success: false, error: (error as Error).message }
} catch (error: any) {
return { success: false, error: error.message }
}
}

View File

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

View File

@@ -1,48 +1,14 @@
import { createClient } from "@/lib/supabase-server"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { UserForm } from "@/components/dashboard/user-form"
import { getProfile } from "@/lib/data"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export default async function ProfilePage() {
const supabase = createClient()
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 (
<div className="flex-1 space-y-4 p-8 pt-6">
<div className="flex items-center justify-between space-y-2">
@@ -54,18 +20,28 @@ export default async function ProfilePage() {
<CardHeader>
<CardTitle>Genel Bilgiler</CardTitle>
<CardDescription>
Kişisel profil bilgilerinizi buradan güncelleyebilirsiniz.
Kişisel profil bilgileriniz.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center space-x-4 mb-6">
<div className="flex items-center space-x-4">
<Avatar className="h-20 w-20">
<AvatarImage src="/avatars/01.png" alt="@parakasa" />
<AvatarFallback>PK</AvatarFallback>
</Avatar>
<Button variant="outline">Fotoğraf Değiştir</Button>
</div>
<UserForm initialData={initialData} mode="profile" />
<div className="space-y-1">
<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>
</Card>
</div>

View File

@@ -1,15 +0,0 @@
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

@@ -0,0 +1,42 @@
"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,79 +1,45 @@
import { createClient } from "@/lib/supabase-server"
import { SettingsTabs } from "@/components/dashboard/settings-tabs"
import { getSmsSettings } from "@/lib/sms/actions"
import { SiteContent } from "@/types/cms"
import { SiteSettingsForm } from "@/components/dashboard/site-settings-form"
import { AppearanceForm } from "@/components/dashboard/appearance-form"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Button } from "@/components/ui/button"
export default async function SettingsPage() {
const supabase = createClient()
// Fetch SMS settings
const smsResponse = await getSmsSettings()
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')
// Fetch site settings
const { data: settings } = await supabase
.from('site_settings')
.select('*')
.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)
}
})
.single()
return (
<div className="flex-1 space-y-4 p-8 pt-6">
<h2 className="text-3xl font-bold tracking-tight">Ayarlar</h2>
<SettingsTabs
smsSettings={smsSettings}
users={profiles || []}
contents={mergedContents}
/>
{/* Site General Settings */}
<div className="grid gap-4">
<SiteSettingsForm initialData={settings} />
</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>
)
}

View File

@@ -1,34 +0,0 @@
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

@@ -1,124 +0,0 @@
"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

@@ -1,14 +0,0 @@
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

@@ -1,85 +0,0 @@
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

@@ -1,65 +0,0 @@
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

@@ -1,9 +0,0 @@
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,7 +64,6 @@ async function getUserDetails(userId: string) {
firstName,
lastName,
email: user.email || "",
role: profile.role as "admin" | "user",
phone: profile.phone || ""
role: profile.role as "admin" | "user"
}
}

View File

@@ -3,6 +3,7 @@
import { createClient } from "@/lib/supabase-server"
import { createClient as createSupabaseClient } from "@supabase/supabase-js"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
// WARNING: specialized client for admin actions only
// This requires SUPABASE_SERVICE_ROLE_KEY to be set in .env.local
@@ -17,13 +18,21 @@ const supabaseAdmin = createSupabaseClient(
}
)
export async function createUser(firstName: string, lastName: string, email: string, password: string, role: 'admin' | 'user', phone?: string) {
export async function createUser(firstName: string, lastName: string, email: string, password: string, role: 'admin' | 'user') {
const supabase = createClient()
// 1. Check if current user is admin
try {
await assertAdmin()
} catch (error) {
return { error: (error as Error).message }
const { data: { user: currentUser } } = await supabase.auth.getUser()
if (!currentUser) return { 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') {
return { error: "Yetkisiz işlem. Sadece yöneticiler kullanıcı oluşturabilir." }
}
// 2. Create user using Admin client
@@ -50,8 +59,7 @@ export async function createUser(firstName: string, lastName: string, email: str
.insert({
id: newUser.user.id,
full_name: `${firstName} ${lastName}`.trim(),
role: role,
phone: phone
role: role
})
if (profileError) {
@@ -65,13 +73,14 @@ export async function createUser(firstName: string, lastName: string, email: str
}
export async function deleteUser(userId: string) {
const supabase = createClient()
// Check admin
try {
await assertAdmin()
} catch (error) {
return { error: (error as Error).message }
}
const { data: { user: currentUser } } = await supabase.auth.getUser()
if (!currentUser) return { error: "Oturum açmanız gerekiyor." }
const { data: profile } = await supabase.from('profiles').select('role').eq('id', currentUser.id).single()
if (profile?.role !== 'admin') return { error: "Yetkisiz işlem." }
// Delete user
const { error } = await supabaseAdmin.auth.admin.deleteUser(userId)
@@ -82,29 +91,30 @@ export async function deleteUser(userId: string) {
return { success: true }
}
export async function updateUser(userId: string, data: { firstName: string, lastName: string, email: string, password?: string, role: 'admin' | 'user', phone?: string }) {
export async function updateUser(userId: string, data: { firstName: string, lastName: string, email: string, password?: string, role: 'admin' | 'user' }) {
const supabase = createClient()
// Check admin
try {
await assertAdmin()
} catch (error) {
return { error: (error as Error).message }
}
const { data: { user: currentUser } } = await supabase.auth.getUser()
if (!currentUser) return { error: "Oturum açmanız gerekiyor." }
// Check if current user is admin
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)
const { error: profileError } = await supabaseAdmin
.from('profiles')
.update({
full_name: `${data.firstName} ${data.lastName}`.trim(),
role: data.role,
phone: data.phone
role: data.role
})
.eq('id', userId)
if (profileError) return { error: "Profil güncellenemedi: " + profileError.message }
// 2. Update Auth (Email and Password)
const authUpdates: { email: string; user_metadata: { full_name: string }; password?: string } = {
const authUpdates: any = {
email: data.email,
user_metadata: {
full_name: `${data.firstName} ${data.lastName}`.trim()
@@ -121,50 +131,3 @@ export async function updateUser(userId: string, data: { firstName: string, last
revalidatePath("/dashboard/users")
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,11 +1,8 @@
import { getProfile } from "@/lib/data"
import { createClient } from "@/lib/supabase-server"
import { redirect } from "next/navigation"
import { Sidebar } from "@/components/dashboard/sidebar"
import { DashboardHeader } from "@/components/dashboard/header"
import { AutoLogoutHandler } from "@/components/dashboard/auto-logout-handler"
export default async function DashboardLayout({
children,
}: Readonly<{
@@ -18,11 +15,8 @@ export default async function DashboardLayout({
redirect("/login")
}
const profile = await getProfile(user.id)
return (
<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">
<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>
@@ -30,7 +24,7 @@ export default async function DashboardLayout({
<Sidebar className="flex-1" />
</aside>
<div className="flex flex-col sm:gap-4 sm:py-4 sm:pl-64">
<DashboardHeader user={user} profile={profile} />
<DashboardHeader />
<main className="grid flex-1 items-start gap-4 p-4 sm:px-6 sm:py-0 md:gap-8">
{children}
</main>

View File

@@ -1,27 +0,0 @@
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,22 +1,61 @@
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"
"use client"
export default async function ContactPage() {
const siteSettings = await getSiteContents()
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 { 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 (
<div className="container py-12 md:py-24">
<div className="text-center mb-8 md:mb-12">
<h1 className="text-3xl md:text-4xl font-bold tracking-tight mb-4 font-outfit">İletişime Geçin</h1>
<div className="text-center mb-12">
<h1 className="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">
Sorularınız, teklif talepleriniz veya teknik destek için bize ulaşın.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 max-w-5xl mx-auto">
<div className="grid md:grid-cols-2 gap-12 max-w-5xl mx-auto">
<div className="space-y-8">
<div className="space-y-6">
<h2 className="text-2xl font-semibold">İletişim Bilgileri</h2>
@@ -24,8 +63,9 @@ export default async function ContactPage() {
<MapPin className="w-6 h-6 text-primary mt-1" />
<div>
<p className="font-medium">Merkez Ofis & Showroom</p>
<p className="text-slate-600 dark:text-slate-400 whitespace-pre-wrap">
{siteSettings.contact_address || "Organize Sanayi Bölgesi, 12. Cadde No: 45\nBaşakşehir, İstanbul"}
<p className="text-slate-600 dark:text-slate-400">
Organize Sanayi Bölgesi, 12. Cadde No: 45<br />
Başakşehir, İstanbul
</p>
</div>
</div>
@@ -33,60 +73,110 @@ export default async function ContactPage() {
<Phone className="w-6 h-6 text-primary" />
<div>
<p className="font-medium">Telefon</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>
<p className="text-slate-600 dark:text-slate-400">+90 (212) 555 00 00</p>
</div>
</div>
<div className="flex items-center space-x-4">
<Mail className="w-6 h-6 text-primary" />
<div>
<p className="font-medium">E-posta</p>
<p className="text-slate-600 dark:text-slate-400">
{siteSettings.contact_email || "info@parakasa.com"}
<p className="text-slate-600 dark:text-slate-400">info@parakasa.com</p>
</div>
</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>
<Button onClick={() => setIsSuccess(false)} variant="outline">
Yeni Mesaj Gönder
</Button>
</div>
</div>
<div className="pt-4 border-t">
<h3 className="text-lg font-semibold mb-3">Sosyal Medya</h3>
<div className="flex gap-4">
{siteSettings.social_instagram && (
<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">
<Instagram className="h-5 w-5" />
</Link>
)}
{siteSettings.social_youtube && (
<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ş.
<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 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
<ContactForm />
// 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>
</div>
</div>
)

View File

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

View File

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

View File

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

View File

@@ -1,104 +0,0 @@
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,31 +1,48 @@
import { createClient } from "@/lib/supabase-server"
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import Link from "next/link"
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",
},
]
// 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()
export default function ProductsPage() {
return (
<div className="container py-12 md:py-24">
<div className="flex flex-col items-center mb-12 text-center">
@@ -36,50 +53,27 @@ export default async function ProductsPage() {
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{products && products.length > 0 ? (
products.map((product) => (
<Card key={product.id} className="overflow-hidden border-0 shadow-md hover:shadow-xl transition-all duration-300 group">
{products.map((product) => (
<Card key={product.id} className="overflow-hidden border-0 shadow-md hover:shadow-xl transition-all duration-300">
<div className="aspect-[4/5] relative bg-slate-100 dark:bg-slate-800">
{product.image_url ? (
<Image
src={product.image_url}
alt={product.name}
fill
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
{/* Placeholder for real images */}
<div className="absolute inset-0 flex items-center justify-center text-slate-400">
<span className="text-sm">Görsel Yok</span>
<span className="text-sm">Görsel: {product.name}</span>
</div>
)}
</div>
<CardHeader className="p-4">
<div className="flex justify-between items-start">
<div className="w-full">
<div className="flex justify-between items-center w-full">
<div>
<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>
</div>
</div>
</CardHeader>
<CardFooter className="p-4 pt-0">
<Button className="w-full" variant="outline" asChild>
<Link href={`/products/${product.id}`}>
Detayları İncele
</Link>
</Button>
<Button className="w-full">Detayları İncele</Button>
</CardFooter>
</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>
)

View File

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

View File

@@ -1,130 +0,0 @@
"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,4 +1,3 @@
@import 'react-phone-number-input/style.css';
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -1,50 +1,26 @@
import localFont from "next/font/local";
import type { Metadata } from "next";
import { Inter, Outfit } from "next/font/google";
import { Toaster } from "sonner";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { getSiteContents } from "@/lib/data";
const inter = localFont({
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",
});
const inter = Inter({ subsets: ["latin"] });
const outfit = Outfit({ subsets: ["latin"], variable: "--font-outfit" });
import { getSiteSettings } from "@/lib/site-settings";
export async function generateMetadata() {
const settings = await getSiteContents();
const settings = await getSiteSettings();
return {
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.",
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.",
};
}
import { ThemeProvider } from "@/components/theme-provider"
// ... imports
export default function RootLayout({
children,
}: Readonly<{

Binary file not shown.

Binary file not shown.

View File

@@ -1,137 +0,0 @@
"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

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

View File

@@ -1,130 +0,0 @@
'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

@@ -1,187 +0,0 @@
'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,12 +6,7 @@ import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
import { Sidebar } from "@/components/dashboard/sidebar"
import { UserNav } from "@/components/dashboard/user-nav"
interface DashboardHeaderProps {
user: { email?: string | null } | null
profile: { full_name?: string | null, role?: string | null } | null
}
export function DashboardHeader({ user, profile }: DashboardHeaderProps) {
export function DashboardHeader() {
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">
<Sheet>
@@ -33,7 +28,7 @@ export function DashboardHeader({ user, profile }: DashboardHeaderProps) {
<div className="w-full flex-1">
{/* Breadcrumb or Search could go here */}
</div>
<UserNav user={user} profile={profile} />
<UserNav />
</header>
)
}

View File

@@ -1,113 +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 {
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"
import { useState } from "react"
import { useForm, type Resolver } from "react-hook-form"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { Button } from "@/components/ui/button"
@@ -23,44 +23,31 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
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"
import { Loader2 } from "lucide-react"
const productSchema = z.object({
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"),
description: z.string().optional(),
price: z.coerce.number().min(0, "Fiyat 0'dan küçük olamaz"),
image_url: z.string().optional(),
is_active: z.boolean().default(true),
images: z.array(z.string()).optional()
})
type ProductFormValues = z.infer<typeof productSchema>
import { createProduct, updateProduct } from "@/app/(dashboard)/dashboard/products/actions"
// Define the shape of data coming from Supabase
interface Product {
id: number
name: string
product_code?: string | null
category: string
description: string | null
price: number
image_url: string | null
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 {
@@ -70,120 +57,24 @@ interface ProductFormProps {
export function ProductForm({ initialData }: ProductFormProps) {
const router = useRouter()
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>({
resolver: zodResolver(productSchema) as Resolver<ProductFormValues>,
resolver: zodResolver(productSchema) as any,
defaultValues: initialData ? {
name: initialData.name,
product_code: initialData.product_code || "",
category: initialData.category,
description: initialData.description || "",
price: initialData.price,
image_url: initialData.image_url || "",
is_active: initialData.is_active ?? true,
images: initialData.image_url ? [initialData.image_url] : []
} : {
name: "",
product_code: "",
category: "",
description: "",
price: 0,
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) {
try {
setLoading(true)
@@ -203,7 +94,7 @@ export function ProductForm({ initialData }: ProductFormProps) {
toast.success(initialData ? "Ürün güncellendi" : "Ürün başarıyla oluşturuldu")
router.push("/dashboard/products")
router.refresh()
} catch {
} catch (error) {
toast.error("Bir aksilik oldu")
} finally {
setLoading(false)
@@ -213,31 +104,6 @@ export function ProductForm({ initialData }: ProductFormProps) {
return (
<Form {...form}>
<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
control={form.control}
name="name"
@@ -251,22 +117,8 @@ export function ProductForm({ initialData }: ProductFormProps) {
</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-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="category"
@@ -291,7 +143,6 @@ export function ProductForm({ initialData }: ProductFormProps) {
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
@@ -306,54 +157,24 @@ export function ProductForm({ initialData }: ProductFormProps) {
</FormItem>
)}
/>
</div>
<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>
<FormField
control={form.control}
name="image_url"
render={({ field }) => (
<FormItem>
<FormLabel>Görsel URL (Opsiyonel)</FormLabel>
<FormControl>
<Input placeholder="https://..." {...field} />
</FormControl>
<FormDescription>
Birden fazla resim seçebilirsiniz. Resimler otomatik olarak sıkıştırılacaktır.
Ürün görseli için şimdilik dış bağlantı kullanın.
</FormDescription>
</div>
{/* Hidden input for main image url fallback if needed */}
<input type="hidden" {...form.register("image_url")} />
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
@@ -369,8 +190,8 @@ export function ProductForm({ initialData }: ProductFormProps) {
)}
/>
<Button type="submit" disabled={loading || uploading}>
{(loading || uploading) && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{initialData ? "Güncelle" : "Oluştur"}
</Button>
</form>

View File

@@ -1,80 +0,0 @@
"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 { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import { LayoutDashboard, Package, ShoppingCart, Settings, Globe, Tags, Users, MessageSquare, History } from "lucide-react"
import { LayoutDashboard, Package, ShoppingCart, Users, Settings, Globe, Tags } from "lucide-react"
const sidebarItems = [
{
@@ -16,25 +16,20 @@ const sidebarItems = [
href: "/dashboard/products",
icon: Package,
},
{
title: "Kategoriler",
href: "/dashboard/categories",
icon: Tags,
},
{
title: "Siparişler",
href: "/dashboard/orders",
icon: ShoppingCart,
},
{
title: "Müşteriler",
href: "/dashboard/customers",
icon: Users,
title: "Kategoriler",
href: "/dashboard/categories",
icon: Tags,
},
{
title: "SMS Gönder",
href: "/dashboard/sms",
icon: MessageSquare,
title: "Kullanıcılar",
href: "/dashboard/users",
icon: Users,
},
{
title: "Ayarlar",
@@ -48,7 +43,7 @@ const sidebarItems = [
},
]
type SidebarProps = React.HTMLAttributes<HTMLDivElement>
interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> { }
export function Sidebar({ className }: SidebarProps) {
const pathname = usePathname()
@@ -61,7 +56,7 @@ export function Sidebar({ className }: SidebarProps) {
Yönetim
</h2>
<div className="space-y-1">
{sidebarItems.filter(i => !i.href.includes('/sms')).map((item) => (
{sidebarItems.map((item) => (
<Link
key={item.href}
href={item.href}
@@ -76,34 +71,6 @@ export function Sidebar({ className }: SidebarProps) {
))}
</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>
)

View File

@@ -0,0 +1,164 @@
"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

@@ -1,213 +0,0 @@
"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

@@ -1,480 +0,0 @@
"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

@@ -1,198 +0,0 @@
"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,13 +1,12 @@
"use client"
import { useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
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 { PhoneInput } from "@/components/ui/phone-input"
import {
Form,
FormControl,
@@ -26,59 +25,35 @@ import {
import { Card, CardContent } from "@/components/ui/card"
import { toast } from "sonner"
import { Loader2 } from "lucide-react"
import { createUser, updateUser, updateProfile } from "@/app/(dashboard)/dashboard/users/actions"
import { createUser, updateUser } from "@/app/(dashboard)/dashboard/users/actions"
const userSchema = z.object({
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."),
email: z.string().email("Geçerli bir e-posta adresi giriniz."),
password: z.string().optional(),
confirmPassword: z.string().optional(),
password: z.string().optional(), // Password is optional on edit
role: z.enum(["admin", "user"]),
phone: z.string().optional(),
}).superRefine((data, ctx) => {
// 1. Password match check
if (data.password && data.password !== data.confirmPassword) {
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"],
});
}
});
}).refine((data) => {
// If we are creating a NEW user (no ID passed in props effectively, but schema doesn't know props),
// we generally want password required. But here we'll handle it in the component logic or strictly separate schemas.
// For simplicity, we make password optional in Zod but check it in onSubmit if it's a create action.
return true
})
type UserFormValues = z.infer<typeof userSchema>
interface UserFormProps {
initialData?: {
id?: string
id: string
firstName: string
lastName: string
email: string
role: "admin" | "user"
phone?: string
}
mode?: "admin" | "profile"
}
export function UserForm({ initialData, mode = "admin" }: UserFormProps) {
export function UserForm({ initialData }: UserFormProps) {
const router = useRouter()
const searchParams = useSearchParams()
const returnTo = searchParams.get("returnTo")
const [loading, setLoading] = useState(false)
const form = useForm<UserFormValues>({
@@ -88,17 +63,13 @@ export function UserForm({ initialData, mode = "admin" }: UserFormProps) {
lastName: initialData.lastName,
email: initialData.email,
password: "", // Empty password means no change
confirmPassword: "",
role: initialData.role,
phone: initialData.phone,
} : {
firstName: "",
lastName: "",
email: "",
password: "",
confirmPassword: "",
role: "user",
phone: "",
},
})
@@ -106,28 +77,17 @@ export function UserForm({ initialData, mode = "admin" }: UserFormProps) {
setLoading(true)
try {
let result;
if (mode === "profile") {
// 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
})
if (initialData) {
// Update
result = await updateUser(initialData.id, data)
} else {
// Admin create mode
// Password requirement is now handled by Zod Schema
result = await createUser(data.firstName, data.lastName, data.email, data.password!, data.role, data.phone)
// Create
if (!data.password || data.password.length < 6) {
toast.error("Yeni kullanıcı için şifre gereklidir (min 6 karakter).")
setLoading(false)
return
}
result = await createUser(data.firstName, data.lastName, data.email, data.password, data.role)
}
if (result.error) {
@@ -135,20 +95,10 @@ export function UserForm({ initialData, mode = "admin" }: UserFormProps) {
return
}
toast.success(
mode === "profile"
? "Profil bilgileriniz güncellendi."
: initialData ? "Kullanıcı güncellendi." : "Kullanıcı oluşturuldu."
)
router.refresh()
router.refresh()
if (returnTo) {
router.push(returnTo)
} else if (mode === "admin") {
toast.success(initialData ? "Kullanıcı güncellendi." : "Kullanıcı oluşturuldu.")
router.push("/dashboard/users")
}
} catch {
router.refresh()
} catch (error) {
toast.error("Bir sorun oluştu.")
} finally {
setLoading(false)
@@ -189,20 +139,6 @@ export function UserForm({ initialData, mode = "admin" }: UserFormProps) {
/>
</div>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Telefon</FormLabel>
<FormControl>
<PhoneInput placeholder="555 123 4567" defaultCountry="TR" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
@@ -210,21 +146,13 @@ export function UserForm({ initialData, mode = "admin" }: UserFormProps) {
<FormItem>
<FormLabel>E-posta</FormLabel>
<FormControl>
<Input
placeholder="ahmet@parakasa.com"
{...field}
disabled={mode === "profile"}
className={mode === "profile" ? "bg-muted" : ""}
/>
<Input placeholder="ahmet@parakasa.com" {...field} />
</FormControl>
{mode === "profile" && <p className="text-[0.8rem] text-muted-foreground">E-posta adresi değiştirilemez.</p>}
<FormMessage />
</FormItem>
)}
/>
{mode === "admin" && (
<>
<FormField
control={form.control}
name="password"
@@ -239,20 +167,6 @@ export function UserForm({ initialData, mode = "admin" }: UserFormProps) {
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Şifre Tekrar</FormLabel>
<FormControl>
<Input type="password" placeholder="******" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
@@ -274,19 +188,10 @@ export function UserForm({ initialData, mode = "admin" }: UserFormProps) {
</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">
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{mode === "profile" ? "Değişiklikleri Kaydet" : (initialData ? "Kaydet" : "Kullanıcı Oluştur")}
Kullanıcı Oluştur
</Button>
</form>
</Form>

View File

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

View File

@@ -1,67 +0,0 @@
"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,20 +1,15 @@
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 (
<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="flex flex-col gap-2">
<Link href="/" className="flex items-center gap-2">
<span className="text-xl font-bold tracking-tighter">{siteSettings.site_title || "PARAKASA"}</span>
<span className="text-xl font-bold tracking-tighter">PARAKASA</span>
</Link>
<p className="text-sm text-muted-foreground">
{siteSettings.site_description || "Yüksek güvenlikli çelik kasa ve para sayma sistemleri için güvenilir çözüm ortağınız."}
Yüksek güvenlikli çelik kasa ve para sayma sistemleri için güvenilir çözüm ortağınız.
</p>
</div>
<div className="flex flex-col gap-2">
@@ -49,36 +44,19 @@ export async function Footer() {
</div>
<div className="flex flex-col gap-2">
<h3 className="font-semibold">Bize Ulaşın</h3>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{siteSettings.contact_address || "İstanbul, Türkiye"}
<p className="text-sm text-muted-foreground">
İstanbul, Türkiye
</p>
<p className="text-sm text-muted-foreground">
{siteSettings.contact_phone || "+90 212 000 00 00"}
</p>
<p className="text-sm text-muted-foreground">
{siteSettings.contact_email}
+90 212 000 00 00
</p>
<div className="flex gap-4 mt-4">
{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>
)}
{/* Social Media Icons would go here */}
</div>
</div>
</div>
<div className="container px-4 md:px-6 mt-8 pt-8 border-t text-center text-sm text-muted-foreground">
© {new Date().getFullYear()} {siteSettings.site_title || "ParaKasa"}. Tüm hakları saklıdır.
© 2026 ParaKasa. Tüm hakları saklıdır.
</div>
</footer>
)

View File

@@ -4,33 +4,19 @@ import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
import { Input } from "@/components/ui/input"
import { createClient } from "@/lib/supabase-server"
import { getSiteContents } from "@/lib/data"
import Image from "next/image"
export async function Navbar() {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
const siteSettings = await getSiteContents()
return (
<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="mr-8 hidden md:flex">
<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">
{siteSettings.site_title || "PARAKASA"}
PARAKASA
</span>
)}
</Link>
<nav className="flex items-center space-x-6 text-sm font-medium">
<Link
@@ -66,19 +52,8 @@ export async function Navbar() {
</Button>
</SheetTrigger>
<SheetContent side="left" className="pr-0">
<Link href="/" className="flex items-center mb-6">
{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 href="/" className="flex items-center">
<span className="font-bold">PARAKASA</span>
</Link>
<div className="flex flex-col gap-4 mt-8">
<Link href="/products">Ürünler</Link>
@@ -88,26 +63,8 @@ export async function Navbar() {
</SheetContent>
</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="w-full flex-1 md:w-auto md:flex-none hidden md:block">
<div className="w-full flex-1 md:w-auto md:flex-none">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input

View File

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

View File

@@ -1,32 +0,0 @@
"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

@@ -1,141 +0,0 @@
"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

@@ -1,25 +0,0 @@
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

@@ -1,58 +0,0 @@
"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

@@ -1,15 +0,0 @@
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 }

View File

@@ -1,55 +0,0 @@
"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))
// 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." }
}

View File

@@ -1,88 +0,0 @@
'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 }
}

View File

@@ -1,31 +0,0 @@
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
})

8
lib/site-settings.ts Normal file
View File

@@ -0,0 +1,8 @@
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
})

View File

@@ -1,203 +0,0 @@
"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 }
}
}

View File

@@ -1,88 +0,0 @@
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 };
}
}
}

View File

@@ -1,57 +0,0 @@
"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

@@ -1,124 +0,0 @@
"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 }
}

Binary file not shown.

BIN
lint.log

Binary file not shown.

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

8
make_admin.sql Normal file
View File

@@ -0,0 +1,8 @@
-- 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,19 +38,11 @@ export async function middleware(request: NextRequest) {
} = await supabase.auth.getUser();
// Protected routes
if (request.nextUrl.pathname.startsWith("/dashboard")) {
if (!user) {
if (!user && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
// 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
// Redirect to dashboard if logged in and trying to access auth pages
if (user && (request.nextUrl.pathname.startsWith("/login") || request.nextUrl.pathname.startsWith("/signup"))) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}

View File

@@ -1,5 +0,0 @@
-- 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

@@ -1,14 +0,0 @@
-- 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')
);

View File

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

View File

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

View File

@@ -1,14 +0,0 @@
// 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,20 +10,16 @@
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@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-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.89.0",
"browser-image-compression": "^2.0.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -34,26 +30,20 @@
"react": "^18",
"react-dom": "^18",
"react-hook-form": "^7.70.0",
"react-icons": "^5.5.0",
"react-phone-number-input": "^3.4.14",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zod": "^4.3.5"
},
"devDependencies": {
"@types/node": "^20",
"@types/pg": "^8.16.0",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.4.23",
"dotenv": "^17.2.3",
"eslint": "^8",
"eslint-config-next": "14.2.16",
"pg": "^8.17.2",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"ts-node": "^10.9.2",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
@@ -71,30 +61,6 @@
"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": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
@@ -698,36 +664,6 @@
}
}
},
"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": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
@@ -1277,37 +1213,6 @@
}
}
},
"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": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
@@ -1462,36 +1367,6 @@
}
}
},
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@@ -1803,34 +1678,6 @@
"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": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -1858,18 +1705,6 @@
"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": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
@@ -2481,19 +2316,6 @@
"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": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -2898,15 +2720,6 @@
"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": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
@@ -3109,12 +2922,6 @@
"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": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -3180,19 +2987,6 @@
"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": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3373,16 +3167,6 @@
"dev": true,
"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": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -3403,19 +3187,6 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -4733,27 +4504,6 @@
"dev": true,
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -5378,12 +5128,6 @@
"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": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -5455,13 +5199,6 @@
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -5699,6 +5436,7 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -5972,103 +5710,6 @@
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6281,49 +5922,6 @@
"dev": true,
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -6338,6 +5936,7 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@@ -6417,38 +6016,13 @@
"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": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/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": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -6957,16 +6531,6 @@
"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": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -7470,57 +7034,6 @@
"dev": true,
"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": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -7828,19 +7341,6 @@
"dev": true,
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -8085,26 +7585,6 @@
}
}
},
"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": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 KiB

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.

Binary file not shown.

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