Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7e809d194 |
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": [
|
|
||||||
"next/core-web-vitals",
|
|
||||||
"next/typescript"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
11
PLANLAMA.md
11
PLANLAMA.md
@@ -17,16 +17,7 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Mevcut Durum (Tamamlananlar)
|
## 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] Temel Site Ayarları (Başlık, İletişim).
|
||||||
- [x] Ürün Yönetimi (Temel CRUD).
|
- [x] Ürün Yönetimi (Temel CRUD).
|
||||||
- [x] Kategori Yönetimi (Arayüz hazır, veritabanı bekleniyor).
|
- [x] Kategori Yönetimi (Arayüz hazır, veritabanı bekleniyor).
|
||||||
|
|
||||||
## 3. Tamamlanan Ek Özellikler (28.01.2026)
|
|
||||||
- [x] **NetGSM Entegrasyonu:** Login için SMS doğrulama (2FA) eklendi.
|
|
||||||
- [x] **Oto-Çıkış:** 15dk hareketsizlikte otomatik çıkış.
|
|
||||||
- [x] **Ürün Geliştirmeleri:**
|
|
||||||
- Aktif/Pasif durumu.
|
|
||||||
- Çoklu resim yükleme.
|
|
||||||
- Resim optimizasyonu.
|
|
||||||
-
|
|
||||||
@@ -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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createClient } from "@/lib/supabase-server"
|
import { createClient } from "@/lib/supabase-server"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { DollarSign, ShoppingCart, Users, Package } from "lucide-react"
|
import { DollarSign, ShoppingCart, Users, CreditCard, Package } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ export default async function DashboardPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlusIcon(props: React.SVGProps<SVGSVGElement>) {
|
function PlusIcon(props: any) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -3,97 +3,49 @@
|
|||||||
import { createClient } from "@/lib/supabase-server"
|
import { createClient } from "@/lib/supabase-server"
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
|
|
||||||
interface ProductData {
|
export async function createProduct(data: any) {
|
||||||
name: string
|
|
||||||
category: string
|
|
||||||
description?: string
|
|
||||||
price: number
|
|
||||||
image_url?: string
|
|
||||||
is_active?: boolean
|
|
||||||
images?: string[]
|
|
||||||
product_code?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createProduct(data: ProductData) {
|
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
|
|
||||||
|
// Validate data manually or use Zod schema here again securely
|
||||||
|
// For simplicity, we assume data is coming from the strongly typed Client Form
|
||||||
|
// In production, ALWAYS validate server-side strictly.
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Create Product
|
const { error } = await supabase.from("products").insert({
|
||||||
const { data: product, error } = await supabase.from("products").insert({
|
|
||||||
name: data.name,
|
name: data.name,
|
||||||
category: data.category,
|
category: data.category,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
price: data.price,
|
price: data.price,
|
||||||
image_url: data.image_url, // Main image (can be first of images)
|
image_url: data.image_url,
|
||||||
is_active: data.is_active ?? true,
|
})
|
||||||
product_code: data.product_code
|
|
||||||
}).select().single()
|
|
||||||
|
|
||||||
if (error) throw error
|
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")
|
revalidatePath("/dashboard/products")
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
return { success: false, error: (error as Error).message }
|
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()
|
const supabase = createClient()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Update Product
|
|
||||||
const { error } = await supabase.from("products").update({
|
const { error } = await supabase.from("products").update({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
category: data.category,
|
category: data.category,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
price: data.price,
|
price: data.price,
|
||||||
image_url: data.image_url,
|
image_url: data.image_url,
|
||||||
is_active: data.is_active,
|
|
||||||
product_code: data.product_code
|
|
||||||
}).eq("id", id)
|
}).eq("id", id)
|
||||||
|
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
|
|
||||||
// 2. Update Images
|
|
||||||
// Strategy: Delete all and re-insert is simplest for now.
|
|
||||||
// Or better: Differential update. For simplicity in MVP: Delete all for this product and re-insert *if* new images provided.
|
|
||||||
// Actually, if we want to keep existing ones, we need more complex logic.
|
|
||||||
// For now, let's assume the form sends the FULL list of current images.
|
|
||||||
if (data.images) {
|
|
||||||
// Delete old
|
|
||||||
await supabase.from("product_images").delete().eq("product_id", id)
|
|
||||||
|
|
||||||
// Insert new
|
|
||||||
if (data.images.length > 0) {
|
|
||||||
const imageInserts = data.images.map((url, index) => ({
|
|
||||||
product_id: id,
|
|
||||||
image_url: url,
|
|
||||||
display_order: index
|
|
||||||
}))
|
|
||||||
await supabase.from("product_images").insert(imageInserts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath("/dashboard/products")
|
revalidatePath("/dashboard/products")
|
||||||
revalidatePath(`/dashboard/products/${id}`)
|
revalidatePath(`/dashboard/products/${id}`)
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
return { success: false, error: (error as Error).message }
|
return { success: false, error: error.message }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default async function ProductsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border rounded-md overflow-x-auto">
|
<div className="border rounded-md">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|||||||
@@ -1,48 +1,14 @@
|
|||||||
import { createClient } from "@/lib/supabase-server"
|
import { createClient } from "@/lib/supabase-server"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import { UserForm } from "@/components/dashboard/user-form"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
import { getProfile } from "@/lib/data"
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
export default async function ProfilePage() {
|
export default async function ProfilePage() {
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
// Should be protected by middleware but just in case
|
|
||||||
return <div>Lütfen giriş yapın.</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch profile data
|
|
||||||
const profile = await getProfile(user.id)
|
|
||||||
|
|
||||||
if (!profile) {
|
|
||||||
// Fallback for user without profile row?
|
|
||||||
// Or create one on the fly?
|
|
||||||
return <div>Profil verisi bulunamadı.</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Improved name parsing logic
|
|
||||||
const fullName = (profile.full_name || "").trim()
|
|
||||||
const firstSpaceIndex = fullName.indexOf(' ')
|
|
||||||
|
|
||||||
let firstName = fullName
|
|
||||||
let lastName = ""
|
|
||||||
|
|
||||||
if (firstSpaceIndex > 0) {
|
|
||||||
firstName = fullName.substring(0, firstSpaceIndex)
|
|
||||||
lastName = fullName.substring(firstSpaceIndex + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialData = {
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
phone: profile.phone || "",
|
|
||||||
email: user.email || "",
|
|
||||||
role: profile.role || "user"
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-4 p-8 pt-6">
|
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||||
<div className="flex items-center justify-between space-y-2">
|
<div className="flex items-center justify-between space-y-2">
|
||||||
@@ -54,18 +20,28 @@ export default async function ProfilePage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Genel Bilgiler</CardTitle>
|
<CardTitle>Genel Bilgiler</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Kişisel profil bilgilerinizi buradan güncelleyebilirsiniz.
|
Kişisel profil bilgileriniz.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<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">
|
<Avatar className="h-20 w-20">
|
||||||
<AvatarImage src="/avatars/01.png" alt="@parakasa" />
|
<AvatarImage src="/avatars/01.png" alt="@parakasa" />
|
||||||
<AvatarFallback>PK</AvatarFallback>
|
<AvatarFallback>PK</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
<Button variant="outline">Fotoğraf Değiştir</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
42
app/(dashboard)/dashboard/settings/actions.ts
Normal file
42
app/(dashboard)/dashboard/settings/actions.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -1,79 +1,45 @@
|
|||||||
import { createClient } from "@/lib/supabase-server"
|
import { createClient } from "@/lib/supabase-server"
|
||||||
import { SettingsTabs } from "@/components/dashboard/settings-tabs"
|
import { SiteSettingsForm } from "@/components/dashboard/site-settings-form"
|
||||||
import { getSmsSettings } from "@/lib/sms/actions"
|
import { AppearanceForm } from "@/components/dashboard/appearance-form"
|
||||||
import { SiteContent } from "@/types/cms"
|
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() {
|
export default async function SettingsPage() {
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
|
|
||||||
// Fetch SMS settings
|
// Fetch site settings
|
||||||
const smsResponse = await getSmsSettings()
|
const { data: settings } = await supabase
|
||||||
const smsSettings = smsResponse.data || null
|
.from('site_settings')
|
||||||
|
|
||||||
// Fetch Users (Profiles)
|
|
||||||
const { data: profiles } = await supabase
|
|
||||||
.from("profiles")
|
|
||||||
.select("*")
|
|
||||||
.order("created_at", { ascending: false })
|
|
||||||
|
|
||||||
// Fetch Site Contents (CMS)
|
|
||||||
const { data: contents } = await supabase
|
|
||||||
.from('site_contents')
|
|
||||||
.select('*')
|
.select('*')
|
||||||
.order('key')
|
.single()
|
||||||
|
|
||||||
// Define default contents for CMS
|
|
||||||
const DEFAULT_CONTENTS: SiteContent[] = [
|
|
||||||
// General
|
|
||||||
{ key: 'site_title', value: 'ParaKasa', type: 'text', section: 'general' },
|
|
||||||
{ key: 'site_description', value: '', type: 'long_text', section: 'general' },
|
|
||||||
{ key: 'site_logo', value: '', type: 'image_url', section: 'general' },
|
|
||||||
{ key: 'favicon_url', value: '', type: 'image_url', section: 'general' },
|
|
||||||
|
|
||||||
// Home
|
|
||||||
{ key: 'home_hero_title', value: 'GÜVENLİK SINIR <br /> <span class="text-transparent bg-clip-text bg-gradient-to-r from-slate-200 to-slate-500">TANIMAZ</span>', type: 'html', section: 'home' },
|
|
||||||
{ key: 'home_hero_description', value: 'En değerli varlıklarınız için tasarlanmış yüksek güvenlikli çelik kasalar. Modern teknoloji ve zanaatkarlığın mükemmel uyumu.', type: 'long_text', section: 'home' },
|
|
||||||
{ key: 'home_hero_button_text', value: 'Koleksiyonu İncele', type: 'text', section: 'home' },
|
|
||||||
{ key: 'home_hero_bg_image', value: '/images/hero-safe.png', type: 'image_url', section: 'home' },
|
|
||||||
{ key: 'home_categories_title', value: 'Ürün Kategorileri', type: 'text', section: 'home' },
|
|
||||||
{ key: 'home_categories_description', value: 'İhtiyacınıza uygun güvenlik çözümünü seçin.', type: 'text', section: 'home' },
|
|
||||||
|
|
||||||
// Contact
|
|
||||||
{ key: 'contact_phone', value: '', type: 'text', section: 'contact' },
|
|
||||||
{ key: 'contact_email', value: '', type: 'text', section: 'contact' },
|
|
||||||
{ key: 'contact_address', value: '', type: 'long_text', section: 'contact' },
|
|
||||||
{ key: 'social_instagram', value: '', type: 'text', section: 'contact' },
|
|
||||||
{ key: 'social_youtube', value: '', type: 'text', section: 'contact' },
|
|
||||||
{ key: 'social_tiktok', value: '', type: 'text', section: 'contact' },
|
|
||||||
{ key: 'contact_map_embed', value: '', type: 'html', section: 'contact' },
|
|
||||||
|
|
||||||
// SEO
|
|
||||||
{ key: 'meta_keywords', value: '', type: 'text', section: 'seo' },
|
|
||||||
{ key: 'meta_author', value: '', type: 'text', section: 'seo' },
|
|
||||||
|
|
||||||
// Scripts & Analytics
|
|
||||||
{ key: 'google_analytics_id', value: '', type: 'text', section: 'scripts' },
|
|
||||||
{ key: 'facebook_pixel_id', value: '', type: 'text', section: 'scripts' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Merge default contents with existing contents
|
|
||||||
const mergedContents = [...(contents as SiteContent[] || [])]
|
|
||||||
const existingKeys = new Set(mergedContents.map(c => c.key))
|
|
||||||
|
|
||||||
DEFAULT_CONTENTS.forEach(item => {
|
|
||||||
if (!existingKeys.has(item.key)) {
|
|
||||||
mergedContents.push(item)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-4 p-8 pt-6">
|
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||||
<h2 className="text-3xl font-bold tracking-tight">Ayarlar</h2>
|
<h2 className="text-3xl font-bold tracking-tight">Ayarlar</h2>
|
||||||
<SettingsTabs
|
|
||||||
smsSettings={smsSettings}
|
{/* Site General Settings */}
|
||||||
users={profiles || []}
|
<div className="grid gap-4">
|
||||||
contents={mergedContents}
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 || []} />
|
|
||||||
}
|
|
||||||
@@ -64,7 +64,6 @@ async function getUserDetails(userId: string) {
|
|||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
email: user.email || "",
|
email: user.email || "",
|
||||||
role: profile.role as "admin" | "user",
|
role: profile.role as "admin" | "user"
|
||||||
phone: profile.phone || ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { createClient } from "@/lib/supabase-server"
|
import { createClient } from "@/lib/supabase-server"
|
||||||
import { createClient as createSupabaseClient } from "@supabase/supabase-js"
|
import { createClient as createSupabaseClient } from "@supabase/supabase-js"
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
// WARNING: specialized client for admin actions only
|
// WARNING: specialized client for admin actions only
|
||||||
// This requires SUPABASE_SERVICE_ROLE_KEY to be set in .env.local
|
// This requires SUPABASE_SERVICE_ROLE_KEY to be set in .env.local
|
||||||
@@ -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
|
// 1. Check if current user is admin
|
||||||
try {
|
const { data: { user: currentUser } } = await supabase.auth.getUser()
|
||||||
await assertAdmin()
|
if (!currentUser) return { error: "Oturum açmanız gerekiyor." }
|
||||||
} catch (error) {
|
|
||||||
return { error: (error as Error).message }
|
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
|
// 2. Create user using Admin client
|
||||||
@@ -50,8 +59,7 @@ export async function createUser(firstName: string, lastName: string, email: str
|
|||||||
.insert({
|
.insert({
|
||||||
id: newUser.user.id,
|
id: newUser.user.id,
|
||||||
full_name: `${firstName} ${lastName}`.trim(),
|
full_name: `${firstName} ${lastName}`.trim(),
|
||||||
role: role,
|
role: role
|
||||||
phone: phone
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (profileError) {
|
if (profileError) {
|
||||||
@@ -65,13 +73,14 @@ export async function createUser(firstName: string, lastName: string, email: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteUser(userId: string) {
|
export async function deleteUser(userId: string) {
|
||||||
|
const supabase = createClient()
|
||||||
|
|
||||||
// Check admin
|
// Check admin
|
||||||
try {
|
const { data: { user: currentUser } } = await supabase.auth.getUser()
|
||||||
await assertAdmin()
|
if (!currentUser) return { error: "Oturum açmanız gerekiyor." }
|
||||||
} catch (error) {
|
|
||||||
return { error: (error as Error).message }
|
const { data: profile } = await supabase.from('profiles').select('role').eq('id', currentUser.id).single()
|
||||||
}
|
if (profile?.role !== 'admin') return { error: "Yetkisiz işlem." }
|
||||||
|
|
||||||
// Delete user
|
// Delete user
|
||||||
const { error } = await supabaseAdmin.auth.admin.deleteUser(userId)
|
const { error } = await supabaseAdmin.auth.admin.deleteUser(userId)
|
||||||
@@ -82,29 +91,30 @@ export async function deleteUser(userId: string) {
|
|||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUser(userId: string, data: { firstName: string, lastName: string, email: string, password?: string, role: 'admin' | 'user', 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
|
// Check admin
|
||||||
try {
|
const { data: { user: currentUser } } = await supabase.auth.getUser()
|
||||||
await assertAdmin()
|
if (!currentUser) return { error: "Oturum açmanız gerekiyor." }
|
||||||
} catch (error) {
|
|
||||||
return { error: (error as Error).message }
|
// 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)
|
// 1. Update Profile (Role and Name)
|
||||||
const { error: profileError } = await supabaseAdmin
|
const { error: profileError } = await supabaseAdmin
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.update({
|
.update({
|
||||||
full_name: `${data.firstName} ${data.lastName}`.trim(),
|
full_name: `${data.firstName} ${data.lastName}`.trim(),
|
||||||
role: data.role,
|
role: data.role
|
||||||
phone: data.phone
|
|
||||||
})
|
})
|
||||||
.eq('id', userId)
|
.eq('id', userId)
|
||||||
|
|
||||||
if (profileError) return { error: "Profil güncellenemedi: " + profileError.message }
|
if (profileError) return { error: "Profil güncellenemedi: " + profileError.message }
|
||||||
|
|
||||||
// 2. Update Auth (Email and Password)
|
// 2. Update Auth (Email and Password)
|
||||||
const authUpdates: { email: string; user_metadata: { full_name: string }; password?: string } = {
|
const authUpdates: any = {
|
||||||
email: data.email,
|
email: data.email,
|
||||||
user_metadata: {
|
user_metadata: {
|
||||||
full_name: `${data.firstName} ${data.lastName}`.trim()
|
full_name: `${data.firstName} ${data.lastName}`.trim()
|
||||||
@@ -121,50 +131,3 @@ export async function updateUser(userId: string, data: { firstName: string, last
|
|||||||
revalidatePath("/dashboard/users")
|
revalidatePath("/dashboard/users")
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProfile(data: { firstName: string, lastName: string, phone?: string }) {
|
|
||||||
const supabase = createClient()
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
|
||||||
|
|
||||||
if (!user) return { error: "Oturum açmanız gerekiyor." }
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('profiles')
|
|
||||||
.update({
|
|
||||||
full_name: `${data.firstName} ${data.lastName}`.trim(),
|
|
||||||
phone: data.phone
|
|
||||||
})
|
|
||||||
.eq('id', user.id)
|
|
||||||
|
|
||||||
if (error) return { error: "Profil güncellenemedi: " + error.message }
|
|
||||||
|
|
||||||
// Update Auth Metadata as well
|
|
||||||
if (data.firstName || data.lastName) {
|
|
||||||
await supabase.auth.updateUser({
|
|
||||||
data: {
|
|
||||||
full_name: `${data.firstName} ${data.lastName}`.trim()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath("/dashboard/profile")
|
|
||||||
return { success: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertAdmin() {
|
|
||||||
const supabase = createClient()
|
|
||||||
const { data: { user: currentUser } } = await supabase.auth.getUser()
|
|
||||||
if (!currentUser) throw new Error("Oturum açmanız gerekiyor.")
|
|
||||||
|
|
||||||
const { data: profile } = await supabase
|
|
||||||
.from('profiles')
|
|
||||||
.select('role')
|
|
||||||
.eq('id', currentUser.id)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (!profile || profile.role !== 'admin') {
|
|
||||||
throw new Error("Yetkisiz işlem. Sadece yöneticiler bu işlemi gerçekleştirebilir.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentUser
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { getProfile } from "@/lib/data"
|
|
||||||
import { createClient } from "@/lib/supabase-server"
|
import { createClient } from "@/lib/supabase-server"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
import { Sidebar } from "@/components/dashboard/sidebar"
|
import { Sidebar } from "@/components/dashboard/sidebar"
|
||||||
import { DashboardHeader } from "@/components/dashboard/header"
|
import { DashboardHeader } from "@/components/dashboard/header"
|
||||||
|
|
||||||
import { AutoLogoutHandler } from "@/components/dashboard/auto-logout-handler"
|
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
@@ -18,11 +15,8 @@ export default async function DashboardLayout({
|
|||||||
redirect("/login")
|
redirect("/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = await getProfile(user.id)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen w-full flex-col bg-muted/40">
|
<div className="flex min-h-screen w-full flex-col bg-muted/40">
|
||||||
<AutoLogoutHandler />
|
|
||||||
<aside className="fixed inset-y-0 left-0 z-10 hidden w-64 flex-col border-r bg-background sm:flex">
|
<aside className="fixed inset-y-0 left-0 z-10 hidden w-64 flex-col border-r bg-background sm:flex">
|
||||||
<div className="flex h-14 items-center border-b px-4 lg:h-[60px] lg:px-6">
|
<div className="flex h-14 items-center border-b px-4 lg:h-[60px] lg:px-6">
|
||||||
<span className="font-semibold text-lg">ParaKasa Panel</span>
|
<span className="font-semibold text-lg">ParaKasa Panel</span>
|
||||||
@@ -30,7 +24,7 @@ export default async function DashboardLayout({
|
|||||||
<Sidebar className="flex-1" />
|
<Sidebar className="flex-1" />
|
||||||
</aside>
|
</aside>
|
||||||
<div className="flex flex-col sm:gap-4 sm:py-4 sm:pl-64">
|
<div className="flex flex-col sm:gap-4 sm:py-4 sm:pl-64">
|
||||||
<DashboardHeader 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">
|
<main className="grid flex-1 items-start gap-4 p-4 sm:px-6 sm:py-0 md:gap-8">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,61 @@
|
|||||||
import { getSiteContents } from "@/lib/data"
|
"use client"
|
||||||
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"
|
|
||||||
|
|
||||||
export default async function ContactPage() {
|
import { useState } from "react"
|
||||||
const siteSettings = await getSiteContents()
|
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 (
|
return (
|
||||||
<div className="container py-12 md:py-24">
|
<div className="container py-12 md:py-24">
|
||||||
<div className="text-center mb-8 md:mb-12">
|
<div className="text-center mb-12">
|
||||||
<h1 className="text-3xl md:text-4xl font-bold tracking-tight mb-4 font-outfit">İletişime Geçin</h1>
|
<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">
|
<p className="text-muted-foreground max-w-xl mx-auto">
|
||||||
Sorularınız, teklif talepleriniz veya teknik destek için bize ulaşın.
|
Sorularınız, teklif talepleriniz veya teknik destek için bize ulaşın.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid 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-8">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h2 className="text-2xl font-semibold">İletişim Bilgileri</h2>
|
<h2 className="text-2xl font-semibold">İletişim Bilgileri</h2>
|
||||||
@@ -24,8 +63,9 @@ export default async function ContactPage() {
|
|||||||
<MapPin className="w-6 h-6 text-primary mt-1" />
|
<MapPin className="w-6 h-6 text-primary mt-1" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">Merkez Ofis & Showroom</p>
|
<p className="font-medium">Merkez Ofis & Showroom</p>
|
||||||
<p className="text-slate-600 dark:text-slate-400 whitespace-pre-wrap">
|
<p className="text-slate-600 dark:text-slate-400">
|
||||||
{siteSettings.contact_address || "Organize Sanayi Bölgesi, 12. Cadde No: 45\nBaşakşehir, İstanbul"}
|
Organize Sanayi Bölgesi, 12. Cadde No: 45<br />
|
||||||
|
Başakşehir, İstanbul
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,60 +73,110 @@ export default async function ContactPage() {
|
|||||||
<Phone className="w-6 h-6 text-primary" />
|
<Phone className="w-6 h-6 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">Telefon</p>
|
<p className="font-medium">Telefon</p>
|
||||||
<p className="text-slate-600 dark:text-slate-400">
|
<p className="text-slate-600 dark:text-slate-400">+90 (212) 555 00 00</p>
|
||||||
<a
|
|
||||||
href={`tel:${(siteSettings.contact_phone || "+90 (212) 555 00 00").replace(/[^\d+]/g, '')}`}
|
|
||||||
className="hover:text-primary transition-colors hover:underline"
|
|
||||||
>
|
|
||||||
{siteSettings.contact_phone || "+90 (212) 555 00 00"}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<Mail className="w-6 h-6 text-primary" />
|
<Mail className="w-6 h-6 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">E-posta</p>
|
<p className="font-medium">E-posta</p>
|
||||||
<p className="text-slate-600 dark:text-slate-400">
|
<p className="text-slate-600 dark:text-slate-400">info@parakasa.com</p>
|
||||||
{siteSettings.contact_email || "info@parakasa.com"}
|
|
||||||
</p>
|
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{siteSettings.contact_map_embed ? (
|
<div className="aspect-video bg-slate-100 rounded-lg overflow-hidden relative">
|
||||||
<div
|
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
|
||||||
className="aspect-video bg-slate-100 rounded-lg overflow-hidden relative"
|
Harita (Google Maps Embed)
|
||||||
dangerouslySetInnerHTML={{ __html: siteSettings.contact_map_embed }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="aspect-video bg-slate-100 rounded-lg overflow-hidden relative flex items-center justify-center text-muted-foreground">
|
|
||||||
Harita henüz eklenmemiş.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ContactForm />
|
<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-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
|
||||||
|
|
||||||
|
// 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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
|
import Image from "next/image"
|
||||||
|
|
||||||
export default function CorporatePage() {
|
export default function CorporatePage() {
|
||||||
return (
|
return (
|
||||||
@@ -8,7 +8,7 @@ export default function CorporatePage() {
|
|||||||
<section className="space-y-6 text-center">
|
<section className="space-y-6 text-center">
|
||||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight font-outfit">Hakkımızda</h1>
|
<h1 className="text-4xl md:text-5xl font-bold tracking-tight font-outfit">Hakkımızda</h1>
|
||||||
<p className="text-xl text-muted-foreground">
|
<p className="text-xl text-muted-foreground">
|
||||||
1995'ten beri güvenliğinizi en değerli hazinemiz olarak görüyoruz.
|
1995'ten beri güvenliğinizi en değerli hazinemiz olarak görüyoruz.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export default function CorporatePage() {
|
|||||||
</p>
|
</p>
|
||||||
<h2 className="text-3xl font-bold pt-4">Vizyonumuz</h2>
|
<h2 className="text-3xl font-bold pt-4">Vizyonumuz</h2>
|
||||||
<p className="text-slate-600 dark:text-slate-300 leading-relaxed">
|
<p className="text-slate-600 dark:text-slate-300 leading-relaxed">
|
||||||
Türkiye'nin lider çelik kasa üreticisi olarak, global pazarda güvenilirlik ve kalite
|
Türkiye'nin lider çelik kasa üreticisi olarak, global pazarda güvenilirlik ve kalite
|
||||||
denince akla gelen ilk marka olmak. Yenilikçi Ar-Ge çalışmalarımızla güvenlik teknolojilerine
|
denince akla gelen ilk marka olmak. Yenilikçi Ar-Ge çalışmalarımızla güvenlik teknolojilerine
|
||||||
yön vermek.
|
yön vermek.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -34,10 +34,9 @@ export default function LoginPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to 2FA verification instead of dashboard
|
router.push("/dashboard")
|
||||||
router.push("/verify-2fa")
|
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
setError("Bir hata oluştu. Lütfen tekrar deneyin.")
|
setError("Bir hata oluştu. Lütfen tekrar deneyin.")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import Image from "next/image"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { createClient } from "@/lib/supabase-server"
|
import { createClient } from "@/lib/supabase-server"
|
||||||
import { getSiteContents } from "@/lib/data"
|
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
const contents = await getSiteContents()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex flex-col min-h-screen">
|
||||||
@@ -17,7 +15,7 @@ export default async function Home() {
|
|||||||
<section className="relative w-full h-[80vh] flex items-center bg-black overflow-hidden">
|
<section className="relative w-full h-[80vh] flex items-center bg-black overflow-hidden">
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
<Image
|
<Image
|
||||||
src={contents.home_hero_bg_image || "/images/hero-safe.png"}
|
src="/images/hero-safe.png"
|
||||||
alt="Premium Çelik Kasa"
|
alt="Premium Çelik Kasa"
|
||||||
fill
|
fill
|
||||||
className="object-cover opacity-60"
|
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="container relative z-10 px-4 md:px-6">
|
||||||
<div className="max-w-2xl space-y-4">
|
<div className="max-w-2xl space-y-4">
|
||||||
<h1
|
<h1 className="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tighter text-white font-outfit">
|
||||||
className="text-3xl 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>
|
||||||
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>
|
||||||
/>
|
|
||||||
<p className="text-lg md:text-xl text-slate-300 max-w-[600px]">
|
<p className="text-lg md:text-xl text-slate-300 max-w-[600px]">
|
||||||
{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>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-4 mt-8">
|
<div className="flex flex-col sm:flex-row gap-4 mt-8">
|
||||||
<Button size="lg" className="bg-slate-100 text-slate-900 hover:bg-slate-200 font-semibold text-lg px-8" asChild>
|
<Button size="lg" className="bg-slate-100 text-slate-900 hover:bg-slate-200 font-semibold text-lg px-8">
|
||||||
<Link href="/products">{contents.home_hero_button_text || "Koleksiyonu İncele"}</Link>
|
<Link href="/products">Koleksiyonu İncele</Link>
|
||||||
</Button>
|
</Button>
|
||||||
{user && (
|
{user && (
|
||||||
<Button size="lg" variant="outline" className="border-slate-400 text-slate-100 hover:bg-slate-800 hover:text-white font-semibold text-lg px-8">
|
<Button size="lg" variant="outline" className="border-slate-400 text-slate-100 hover:bg-slate-800 hover:text-white font-semibold text-lg px-8">
|
||||||
@@ -56,9 +54,9 @@ export default async function Home() {
|
|||||||
<section className="py-20 md:py-32 bg-slate-50 dark:bg-slate-950">
|
<section className="py-20 md:py-32 bg-slate-50 dark:bg-slate-950">
|
||||||
<div className="container px-4 md:px-6">
|
<div className="container px-4 md:px-6">
|
||||||
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
|
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
|
||||||
<h2 className="text-3xl md:text-5xl font-bold tracking-tighter font-outfit">{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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 Açı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ı açı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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,31 +1,48 @@
|
|||||||
|
|
||||||
import { createClient } from "@/lib/supabase-server"
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import Link from "next/link"
|
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
|
|
||||||
|
const products = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Ev Tipi Çelik Kasa",
|
||||||
|
image: "/images/safe-1.jpg",
|
||||||
|
category: "Ev",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "Ofis Tipi Yanmaz Kasa",
|
||||||
|
image: "/images/safe-2.jpg",
|
||||||
|
category: "Ofis",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "Otel Odası Kasası",
|
||||||
|
image: "/images/safe-3.jpg",
|
||||||
|
category: "Otel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: "Silah Kasası (Tüfek)",
|
||||||
|
image: "/images/safe-4.jpg",
|
||||||
|
category: "Özel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: "Kuyumcu Kasası",
|
||||||
|
image: "/images/safe-5.jpg",
|
||||||
|
category: "Ticari",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
name: "Duvar İçi Gizli Kasa",
|
||||||
|
image: "/images/safe-6.jpg",
|
||||||
|
category: "Ev",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function ProductsPage() {
|
||||||
// Helper to get products
|
|
||||||
async function getProducts() {
|
|
||||||
const supabase = createClient()
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("products")
|
|
||||||
.select("*")
|
|
||||||
.eq("is_active", true)
|
|
||||||
.order("created_at", { ascending: false })
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Error fetching products:", error)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ProductsPage() {
|
|
||||||
const products = await getProducts()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container py-12 md:py-24">
|
<div className="container py-12 md:py-24">
|
||||||
<div className="flex flex-col items-center mb-12 text-center">
|
<div className="flex flex-col items-center mb-12 text-center">
|
||||||
@@ -36,50 +53,27 @@ export default async function ProductsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{products && products.length > 0 ? (
|
{products.map((product) => (
|
||||||
products.map((product) => (
|
<Card key={product.id} className="overflow-hidden border-0 shadow-md hover:shadow-xl transition-all duration-300">
|
||||||
<Card key={product.id} className="overflow-hidden border-0 shadow-md hover:shadow-xl transition-all duration-300 group">
|
<div className="aspect-[4/5] relative bg-slate-100 dark:bg-slate-800">
|
||||||
<div className="aspect-[4/5] relative bg-slate-100 dark:bg-slate-800">
|
{/* Placeholder for real images */}
|
||||||
{product.image_url ? (
|
<div className="absolute inset-0 flex items-center justify-center text-slate-400">
|
||||||
<Image
|
<span className="text-sm">Görsel: {product.name}</span>
|
||||||
src={product.image_url}
|
|
||||||
alt={product.name}
|
|
||||||
fill
|
|
||||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-slate-400">
|
|
||||||
<span className="text-sm">Görsel Yok</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<CardHeader className="p-4">
|
</div>
|
||||||
<div className="flex justify-between items-start">
|
<CardHeader className="p-4">
|
||||||
<div className="w-full">
|
<div className="flex justify-between items-start">
|
||||||
<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>
|
<span className="text-xs font-medium text-slate-500 uppercase tracking-wider">{product.category}</span>
|
||||||
{product.product_code && (
|
<CardTitle className="text-lg mt-1">{product.name}</CardTitle>
|
||||||
<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>
|
</div>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardFooter className="p-4 pt-0">
|
</CardHeader>
|
||||||
<Button className="w-full" variant="outline" asChild>
|
<CardFooter className="p-4 pt-0">
|
||||||
<Link href={`/products/${product.id}`}>
|
<Button className="w-full">Detayları İncele</Button>
|
||||||
Detayları İncele
|
</CardFooter>
|
||||||
</Link>
|
</Card>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { supabase } from "@/lib/supabase"
|
import { supabase } from "@/lib/supabase"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -11,6 +11,7 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
|||||||
import { AlertCircle, Loader2, CheckCircle2 } from "lucide-react"
|
import { AlertCircle, Loader2, CheckCircle2 } from "lucide-react"
|
||||||
|
|
||||||
export default function SignUpPage() {
|
export default function SignUpPage() {
|
||||||
|
const router = useRouter()
|
||||||
const [email, setEmail] = useState("")
|
const [email, setEmail] = useState("")
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -39,7 +40,7 @@ export default function SignUpPage() {
|
|||||||
if (data.user) {
|
if (data.user) {
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
setError("Bir hata oluştu. Lütfen tekrar deneyin.")
|
setError("Bir hata oluştu. Lütfen tekrar deneyin.")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -75,7 +76,7 @@ export default function SignUpPage() {
|
|||||||
<CardHeader className="space-y-1">
|
<CardHeader className="space-y-1">
|
||||||
<CardTitle className="text-2xl font-bold">Hesap Oluştur</CardTitle>
|
<CardTitle className="text-2xl font-bold">Hesap Oluştur</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
ParaKasa'ya katılmak için bilgilerinizi girin
|
ParaKasa'ya katılmak için bilgilerinizi girin
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
@import 'react-phone-number-input/style.css';
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|||||||
@@ -1,50 +1,26 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
import localFont from "next/font/local";
|
import { Inter, Outfit } from "next/font/google";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
|
||||||
import { getSiteContents } from "@/lib/data";
|
|
||||||
|
|
||||||
const inter = localFont({
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
src: [
|
const outfit = Outfit({ subsets: ["latin"], variable: "--font-outfit" });
|
||||||
{ path: "../public/fonts/Inter-Thin.ttf", weight: "100", style: "normal" },
|
|
||||||
{ path: "../public/fonts/Inter-ExtraLight.ttf", weight: "200", style: "normal" },
|
|
||||||
{ path: "../public/fonts/Inter-Light.ttf", weight: "300", style: "normal" },
|
|
||||||
{ path: "../public/fonts/Inter-Regular.ttf", weight: "400", style: "normal" },
|
|
||||||
{ path: "../public/fonts/Inter-Medium.ttf", weight: "500", style: "normal" },
|
|
||||||
{ path: "../public/fonts/Inter-SemiBold.ttf", weight: "600", style: "normal" },
|
|
||||||
{ path: "../public/fonts/Inter-Bold.ttf", weight: "700", style: "normal" },
|
|
||||||
{ path: "../public/fonts/Inter-ExtraBold.ttf", weight: "800", style: "normal" },
|
|
||||||
{ path: "../public/fonts/Inter-Black.ttf", weight: "900", style: "normal" },
|
|
||||||
],
|
|
||||||
variable: "--font-inter",
|
|
||||||
});
|
|
||||||
|
|
||||||
const outfit = localFont({
|
|
||||||
src: [
|
|
||||||
{ path: "../public/fonts/Outfit-Thin.ttf", weight: "100", style: "normal" },
|
|
||||||
{ path: "../public/fonts/Outfit-ExtraLight.ttf", weight: "200", style: "normal" },
|
|
||||||
{ path: "../public/fonts/Outfit-Light.ttf", weight: "300", style: "normal" },
|
|
||||||
{ path: "../public/fonts/Outfit-Regular.ttf", weight: "400", style: "normal" },
|
|
||||||
{ path: "../public/fonts/Outfit-Medium.ttf", weight: "500", style: "normal" },
|
|
||||||
{ path: "../public/fonts/Outfit-SemiBold.ttf", weight: "600", style: "normal" },
|
|
||||||
{ path: "../public/fonts/Outfit-Bold.ttf", weight: "700", style: "normal" },
|
|
||||||
{ path: "../public/fonts/Outfit-ExtraBold.ttf", weight: "800", style: "normal" },
|
|
||||||
{ path: "../public/fonts/Outfit-Black.ttf", weight: "900", style: "normal" },
|
|
||||||
],
|
|
||||||
variable: "--font-outfit",
|
|
||||||
});
|
|
||||||
|
|
||||||
|
import { getSiteSettings } from "@/lib/site-settings";
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const settings = await getSiteContents();
|
const settings = await getSiteSettings();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: settings.site_title || "ParaKasa - Premium Çelik Kasalar",
|
title: settings?.site_title || "ParaKasa - Premium Çelik Kasalar",
|
||||||
description: settings.site_description || "Eviniz ve iş yeriniz için en yüksek güvenlikli çelik kasa ve para sayma çözümleri.",
|
description: settings?.site_description || "Eviniz ve iş yeriniz için en yüksek güvenlikli çelik kasa ve para sayma çözümleri.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider"
|
||||||
|
|
||||||
|
// ... imports
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
|
|||||||
BIN
build_log.txt
BIN
build_log.txt
Binary file not shown.
BIN
build_log_2.txt
BIN
build_log_2.txt
Binary file not shown.
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -62,8 +62,8 @@ export function CategoryForm({ initialData }: CategoryFormProps) {
|
|||||||
try {
|
try {
|
||||||
if (initialData) {
|
if (initialData) {
|
||||||
const result = await updateCategory(initialData.id, data)
|
const result = await updateCategory(initialData.id, data)
|
||||||
if ('error' in result) {
|
if ((result as any).error) {
|
||||||
toast.error(result.error)
|
toast.error((result as any).error)
|
||||||
} else {
|
} else {
|
||||||
toast.success(toastMessage)
|
toast.success(toastMessage)
|
||||||
router.push(`/dashboard/categories`)
|
router.push(`/dashboard/categories`)
|
||||||
@@ -71,15 +71,15 @@ export function CategoryForm({ initialData }: CategoryFormProps) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const result = await createCategory(data)
|
const result = await createCategory(data)
|
||||||
if ('error' in result) {
|
if ((result as any).error) {
|
||||||
toast.error(result.error)
|
toast.error((result as any).error)
|
||||||
} else {
|
} else {
|
||||||
toast.success(toastMessage)
|
toast.success(toastMessage)
|
||||||
router.push(`/dashboard/categories`)
|
router.push(`/dashboard/categories`)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
toast.error("Bir hata oluştu.")
|
toast.error("Bir hata oluştu.")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -90,14 +90,14 @@ export function CategoryForm({ initialData }: CategoryFormProps) {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const result = await deleteCategory(initialData!.id)
|
const result = await deleteCategory(initialData!.id)
|
||||||
if ('error' in result) {
|
if ((result as any).error) {
|
||||||
toast.error(result.error)
|
toast.error((result as any).error)
|
||||||
} else {
|
} else {
|
||||||
toast.success("Kategori silindi.")
|
toast.success("Kategori silindi.")
|
||||||
router.push(`/dashboard/categories`)
|
router.push(`/dashboard/categories`)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
toast.error("Silme işlemi başarısız.")
|
toast.error("Silme işlemi başarısız.")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|||||||
@@ -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'den alınan <iframe> 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -6,12 +6,7 @@ import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
|||||||
import { Sidebar } from "@/components/dashboard/sidebar"
|
import { Sidebar } from "@/components/dashboard/sidebar"
|
||||||
import { UserNav } from "@/components/dashboard/user-nav"
|
import { UserNav } from "@/components/dashboard/user-nav"
|
||||||
|
|
||||||
interface DashboardHeaderProps {
|
export function DashboardHeader() {
|
||||||
user: { email?: string | null } | null
|
|
||||||
profile: { full_name?: string | null, role?: string | null } | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardHeader({ user, profile }: DashboardHeaderProps) {
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-30 flex h-14 items-center gap-4 border-b bg-background px-4 sm:static sm:h-auto sm:border-0 sm:bg-transparent sm:px-6">
|
<header className="sticky top-0 z-30 flex h-14 items-center gap-4 border-b bg-background px-4 sm:static sm:h-auto sm:border-0 sm:bg-transparent sm:px-6">
|
||||||
<Sheet>
|
<Sheet>
|
||||||
@@ -33,7 +28,7 @@ export function DashboardHeader({ user, profile }: DashboardHeaderProps) {
|
|||||||
<div className="w-full flex-1">
|
<div className="w-full flex-1">
|
||||||
{/* Breadcrumb or Search could go here */}
|
{/* Breadcrumb or Search could go here */}
|
||||||
</div>
|
</div>
|
||||||
<UserNav user={user} profile={profile} />
|
<UserNav />
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
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 { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import * as z from "zod"
|
import * as z from "zod"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -23,44 +23,31 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Loader2, X, UploadCloud } from "lucide-react"
|
import { Loader2 } from "lucide-react"
|
||||||
import imageCompression from 'browser-image-compression'
|
|
||||||
import { createClient } from "@/lib/supabase-browser"
|
|
||||||
import Image from "next/image"
|
|
||||||
|
|
||||||
import { createProduct, updateProduct } from "@/app/(dashboard)/dashboard/products/actions"
|
|
||||||
|
|
||||||
const productSchema = z.object({
|
const productSchema = z.object({
|
||||||
name: z.string().min(2, "Ürün adı en az 2 karakter olmalıdır"),
|
name: z.string().min(2, "Ürün adı en az 2 karakter olmalıdır"),
|
||||||
product_code: z.string().optional(),
|
|
||||||
category: z.string().min(1, "Kategori seçiniz"),
|
category: z.string().min(1, "Kategori seçiniz"),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
price: z.coerce.number().min(0, "Fiyat 0'dan küçük olamaz"),
|
price: z.coerce.number().min(0, "Fiyat 0'dan küçük olamaz"),
|
||||||
image_url: z.string().optional(),
|
image_url: z.string().optional(),
|
||||||
is_active: z.boolean().default(true),
|
|
||||||
images: z.array(z.string()).optional()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
type ProductFormValues = z.infer<typeof productSchema>
|
type ProductFormValues = z.infer<typeof productSchema>
|
||||||
|
|
||||||
|
import { createProduct, updateProduct } from "@/app/(dashboard)/dashboard/products/actions"
|
||||||
|
|
||||||
// Define the shape of data coming from Supabase
|
// Define the shape of data coming from Supabase
|
||||||
interface Product {
|
interface Product {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
product_code?: string | null
|
|
||||||
category: string
|
category: string
|
||||||
description: string | null
|
description: string | null
|
||||||
price: number
|
price: number
|
||||||
image_url: string | null
|
image_url: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
is_active?: boolean
|
|
||||||
// images? we might need to fetch them separately if they are in another table,
|
|
||||||
// but for now let's assume update passes them if fetched, or we can handle it later.
|
|
||||||
// Ideally the server component fetches relation.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductFormProps {
|
interface ProductFormProps {
|
||||||
@@ -70,120 +57,24 @@ interface ProductFormProps {
|
|||||||
export function ProductForm({ initialData }: ProductFormProps) {
|
export function ProductForm({ initialData }: ProductFormProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [uploading, setUploading] = useState(false)
|
|
||||||
const [previewImages, setPreviewImages] = useState<string[]>(
|
|
||||||
initialData?.image_url ? [initialData.image_url] : []
|
|
||||||
)
|
|
||||||
// Note: initialData probably only has single image_url field unless we updated the fetch query.
|
|
||||||
// For MVP phase 1, we just sync with image_url or expect 'images' prop if we extended it.
|
|
||||||
// I will add a local state for images.
|
|
||||||
|
|
||||||
const form = useForm<ProductFormValues>({
|
const form = useForm<ProductFormValues>({
|
||||||
resolver: zodResolver(productSchema) as Resolver<ProductFormValues>,
|
resolver: zodResolver(productSchema) as any,
|
||||||
defaultValues: initialData ? {
|
defaultValues: initialData ? {
|
||||||
name: initialData.name,
|
name: initialData.name,
|
||||||
product_code: initialData.product_code || "",
|
|
||||||
category: initialData.category,
|
category: initialData.category,
|
||||||
description: initialData.description || "",
|
description: initialData.description || "",
|
||||||
price: initialData.price,
|
price: initialData.price,
|
||||||
image_url: initialData.image_url || "",
|
image_url: initialData.image_url || "",
|
||||||
is_active: initialData.is_active ?? true,
|
|
||||||
images: initialData.image_url ? [initialData.image_url] : []
|
|
||||||
} : {
|
} : {
|
||||||
name: "",
|
name: "",
|
||||||
product_code: "",
|
|
||||||
category: "",
|
category: "",
|
||||||
description: "",
|
description: "",
|
||||||
price: 0,
|
price: 0,
|
||||||
image_url: "",
|
image_url: "",
|
||||||
is_active: true,
|
|
||||||
images: []
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const files = event.target.files
|
|
||||||
if (!files || files.length === 0) return
|
|
||||||
|
|
||||||
setUploading(true)
|
|
||||||
const supabase = createClient()
|
|
||||||
const uploadedUrls: string[] = [...form.getValues("images") || []]
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
const file = files[i]
|
|
||||||
|
|
||||||
// Compression
|
|
||||||
const options = {
|
|
||||||
maxSizeMB: 1, // Max 1MB
|
|
||||||
maxWidthOrHeight: 1920,
|
|
||||||
useWebWorker: true
|
|
||||||
}
|
|
||||||
|
|
||||||
let compressedFile = file
|
|
||||||
try {
|
|
||||||
compressedFile = await imageCompression(file, options)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Compression error:", error)
|
|
||||||
// Fallback to original
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload
|
|
||||||
const fileExt = file.name.split('.').pop()
|
|
||||||
const fileName = `${Math.random().toString(36).substring(2)}_${Date.now()}.${fileExt}`
|
|
||||||
const filePath = `products/${fileName}`
|
|
||||||
|
|
||||||
const { error: uploadError } = await supabase.storage
|
|
||||||
.from('products') // Assuming 'products' bucket exists
|
|
||||||
.upload(filePath, compressedFile)
|
|
||||||
|
|
||||||
if (uploadError) {
|
|
||||||
console.error(uploadError)
|
|
||||||
toast.error(`Resim yüklenemedi: ${file.name}`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get URL
|
|
||||||
const { data } = supabase.storage.from('products').getPublicUrl(filePath)
|
|
||||||
uploadedUrls.push(data.publicUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update form
|
|
||||||
form.setValue("images", uploadedUrls)
|
|
||||||
// Set first image as main
|
|
||||||
if (uploadedUrls.length > 0) {
|
|
||||||
form.setValue("image_url", uploadedUrls[0])
|
|
||||||
}
|
|
||||||
setPreviewImages(uploadedUrls)
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
toast.error("Yükleme sırasında hata oluştu")
|
|
||||||
} finally {
|
|
||||||
setUploading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeImage = async (index: number) => {
|
|
||||||
const imageToDelete = previewImages[index]
|
|
||||||
|
|
||||||
// If editing an existing product and image is from server (starts with http/https usually)
|
|
||||||
// Local state removal only. Server update happens on Save.
|
|
||||||
// if (initialData && imageToDelete.startsWith("http")) { ... }
|
|
||||||
|
|
||||||
const currentImages = [...form.getValues("images") || []]
|
|
||||||
// Filter out the deleted image URL if it matches
|
|
||||||
const newImages = currentImages.filter(url => url !== imageToDelete)
|
|
||||||
|
|
||||||
form.setValue("images", newImages)
|
|
||||||
|
|
||||||
if (newImages.length > 0) {
|
|
||||||
form.setValue("image_url", newImages[0])
|
|
||||||
} else {
|
|
||||||
form.setValue("image_url", "")
|
|
||||||
}
|
|
||||||
setPreviewImages(newImages)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSubmit(data: ProductFormValues) {
|
async function onSubmit(data: ProductFormValues) {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -203,7 +94,7 @@ export function ProductForm({ initialData }: ProductFormProps) {
|
|||||||
toast.success(initialData ? "Ürün güncellendi" : "Ürün başarıyla oluşturuldu")
|
toast.success(initialData ? "Ürün güncellendi" : "Ürün başarıyla oluşturuldu")
|
||||||
router.push("/dashboard/products")
|
router.push("/dashboard/products")
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} catch {
|
} catch (error) {
|
||||||
toast.error("Bir aksilik oldu")
|
toast.error("Bir aksilik oldu")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -213,60 +104,21 @@ export function ProductForm({ initialData }: ProductFormProps) {
|
|||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 w-full max-w-2xl">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 w-full max-w-2xl">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Ürün Adı</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Çelik Kasa Model X" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<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"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Ürün Adı</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Çelik Kasa Model X" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</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">
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="category"
|
name="category"
|
||||||
@@ -291,70 +143,39 @@ export function ProductForm({ initialData }: ProductFormProps) {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="price"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Fiyat (₺)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="0.00" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="price"
|
name="image_url"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Fiyat (₺)</FormLabel>
|
<FormLabel>Görsel URL (Opsiyonel)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input type="number" placeholder="0.00" {...field} />
|
<Input placeholder="https://..." {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Ürün görseli için şimdilik dış bağlantı kullanın.
|
||||||
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<FormLabel>Ürün Görselleri</FormLabel>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
{previewImages.map((url, i) => (
|
|
||||||
<div key={i} className="relative aspect-square border rounded-md overflow-hidden group">
|
|
||||||
<Image
|
|
||||||
src={url}
|
|
||||||
alt="Preview"
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeImage(i)}
|
|
||||||
className="absolute top-1 right-1 bg-red-500 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<label className="flex flex-col items-center justify-center w-full aspect-square border-2 border-dashed rounded-lg cursor-pointer hover:bg-muted/50 transition-colors">
|
|
||||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
|
||||||
{uploading ? (
|
|
||||||
<Loader2 className="w-8 h-8 text-gray-500 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<UploadCloud className="w-8 h-8 text-gray-500" />
|
|
||||||
)}
|
|
||||||
<p className="mb-2 text-sm text-gray-500">Resim Yükle</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
className="hidden"
|
|
||||||
multiple
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleImageUpload}
|
|
||||||
disabled={uploading}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<FormDescription>
|
|
||||||
Birden fazla resim seçebilirsiniz. Resimler otomatik olarak sıkıştırılacaktır.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hidden input for main image url fallback if needed */}
|
|
||||||
<input type="hidden" {...form.register("image_url")} />
|
|
||||||
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="description"
|
name="description"
|
||||||
@@ -369,8 +190,8 @@ export function ProductForm({ initialData }: ProductFormProps) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" disabled={loading || uploading}>
|
<Button type="submit" disabled={loading}>
|
||||||
{(loading || uploading) && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
{initialData ? "Güncelle" : "Oluştur"}
|
{initialData ? "Güncelle" : "Oluştur"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { LayoutDashboard, Package, ShoppingCart, Settings, Globe, Tags, Users, MessageSquare, History } from "lucide-react"
|
import { LayoutDashboard, Package, ShoppingCart, Users, Settings, Globe, Tags } from "lucide-react"
|
||||||
|
|
||||||
const sidebarItems = [
|
const sidebarItems = [
|
||||||
{
|
{
|
||||||
@@ -16,25 +16,20 @@ const sidebarItems = [
|
|||||||
href: "/dashboard/products",
|
href: "/dashboard/products",
|
||||||
icon: Package,
|
icon: Package,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Kategoriler",
|
|
||||||
href: "/dashboard/categories",
|
|
||||||
icon: Tags,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Siparişler",
|
title: "Siparişler",
|
||||||
href: "/dashboard/orders",
|
href: "/dashboard/orders",
|
||||||
icon: ShoppingCart,
|
icon: ShoppingCart,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Müşteriler",
|
title: "Kategoriler",
|
||||||
href: "/dashboard/customers",
|
href: "/dashboard/categories",
|
||||||
icon: Users,
|
icon: Tags,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "SMS Gönder",
|
title: "Kullanıcılar",
|
||||||
href: "/dashboard/sms",
|
href: "/dashboard/users",
|
||||||
icon: MessageSquare,
|
icon: Users,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Ayarlar",
|
title: "Ayarlar",
|
||||||
@@ -48,7 +43,7 @@ const sidebarItems = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
type SidebarProps = React.HTMLAttributes<HTMLDivElement>
|
interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> { }
|
||||||
|
|
||||||
export function Sidebar({ className }: SidebarProps) {
|
export function Sidebar({ className }: SidebarProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
@@ -61,7 +56,7 @@ export function Sidebar({ className }: SidebarProps) {
|
|||||||
Yönetim
|
Yönetim
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{sidebarItems.filter(i => !i.href.includes('/sms')).map((item) => (
|
{sidebarItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
@@ -76,34 +71,6 @@ export function Sidebar({ className }: SidebarProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-3 py-2">
|
|
||||||
<h2 className="mb-2 px-4 text-lg font-semibold tracking-tight">
|
|
||||||
SMS
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Link
|
|
||||||
href="/dashboard/sms"
|
|
||||||
className={cn(
|
|
||||||
"flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors",
|
|
||||||
pathname === "/dashboard/sms" ? "bg-slate-100 dark:bg-slate-800 text-primary" : "text-slate-500 dark:text-slate-400"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<MessageSquare className="mr-2 h-4 w-4" />
|
|
||||||
Yeni SMS
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/dashboard/sms/logs"
|
|
||||||
className={cn(
|
|
||||||
"flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors",
|
|
||||||
pathname === "/dashboard/sms/logs" ? "bg-slate-100 dark:bg-slate-800 text-primary" : "text-slate-500 dark:text-slate-400"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<History className="mr-2 h-4 w-4" />
|
|
||||||
Geçmiş
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
164
components/dashboard/site-settings-form.tsx
Normal file
164
components/dashboard/site-settings-form.tsx
Normal 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 Açı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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>Açı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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 Aç
|
|
||||||
</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>
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import * as z from "zod"
|
import * as z from "zod"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { PhoneInput } from "@/components/ui/phone-input"
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -26,59 +25,35 @@ import {
|
|||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Loader2 } from "lucide-react"
|
import { Loader2 } from "lucide-react"
|
||||||
import { createUser, updateUser, updateProfile } from "@/app/(dashboard)/dashboard/users/actions"
|
import { createUser, updateUser } from "@/app/(dashboard)/dashboard/users/actions"
|
||||||
|
|
||||||
const userSchema = z.object({
|
const userSchema = z.object({
|
||||||
firstName: z.string().min(2, "Ad en az 2 karakter olmalıdır."),
|
firstName: z.string().min(2, "Ad en az 2 karakter olmalıdır."),
|
||||||
lastName: z.string().min(2, "Soyad en az 2 karakter olmalıdır."),
|
lastName: z.string().min(2, "Soyad en az 2 karakter olmalıdır."),
|
||||||
email: z.string().email("Geçerli bir e-posta adresi giriniz."),
|
email: z.string().email("Geçerli bir e-posta adresi giriniz."),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(), // Password is optional on edit
|
||||||
confirmPassword: z.string().optional(),
|
|
||||||
role: z.enum(["admin", "user"]),
|
role: z.enum(["admin", "user"]),
|
||||||
phone: z.string().optional(),
|
}).refine((data) => {
|
||||||
}).superRefine((data, ctx) => {
|
// If we are creating a NEW user (no ID passed in props effectively, but schema doesn't know props),
|
||||||
// 1. Password match check
|
// we generally want password required. But here we'll handle it in the component logic or strictly separate schemas.
|
||||||
if (data.password && data.password !== data.confirmPassword) {
|
// For simplicity, we make password optional in Zod but check it in onSubmit if it's a create action.
|
||||||
ctx.addIssue({
|
return true
|
||||||
code: z.ZodIssueCode.custom,
|
})
|
||||||
message: "Şifreler eşleşmiyor.",
|
|
||||||
path: ["confirmPassword"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. New user password requirement check
|
|
||||||
// If there is no ID (we can't easily check for ID here in schema without context,
|
|
||||||
// but typically empty password on CREATE is invalid unless handled elsewhere.
|
|
||||||
// However, the component logic implies 'initialData' determines edit/create mode.
|
|
||||||
// For pure schema validation, we often make password required for create, optional for edit.
|
|
||||||
// Since we don't pass 'isCreate' to schema, we can enforce minimum length if provided.
|
|
||||||
if (data.password && data.password.length > 0 && data.password.length < 6) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
message: "Şifre en az 6 karakter olmalıdır.",
|
|
||||||
path: ["password"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
type UserFormValues = z.infer<typeof userSchema>
|
type UserFormValues = z.infer<typeof userSchema>
|
||||||
|
|
||||||
interface UserFormProps {
|
interface UserFormProps {
|
||||||
initialData?: {
|
initialData?: {
|
||||||
id?: string
|
id: string
|
||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
email: string
|
email: string
|
||||||
role: "admin" | "user"
|
role: "admin" | "user"
|
||||||
phone?: string
|
|
||||||
}
|
}
|
||||||
mode?: "admin" | "profile"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserForm({ initialData, mode = "admin" }: UserFormProps) {
|
export function UserForm({ initialData }: UserFormProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const returnTo = searchParams.get("returnTo")
|
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
const form = useForm<UserFormValues>({
|
const form = useForm<UserFormValues>({
|
||||||
@@ -88,17 +63,13 @@ export function UserForm({ initialData, mode = "admin" }: UserFormProps) {
|
|||||||
lastName: initialData.lastName,
|
lastName: initialData.lastName,
|
||||||
email: initialData.email,
|
email: initialData.email,
|
||||||
password: "", // Empty password means no change
|
password: "", // Empty password means no change
|
||||||
confirmPassword: "",
|
|
||||||
role: initialData.role,
|
role: initialData.role,
|
||||||
phone: initialData.phone,
|
|
||||||
} : {
|
} : {
|
||||||
firstName: "",
|
firstName: "",
|
||||||
lastName: "",
|
lastName: "",
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
confirmPassword: "",
|
|
||||||
role: "user",
|
role: "user",
|
||||||
phone: "",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -106,28 +77,17 @@ export function UserForm({ initialData, mode = "admin" }: UserFormProps) {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
let result;
|
let result;
|
||||||
|
if (initialData) {
|
||||||
if (mode === "profile") {
|
// Update
|
||||||
// Profile update mode (self-service)
|
result = await updateUser(initialData.id, data)
|
||||||
result = await updateProfile({
|
|
||||||
firstName: data.firstName,
|
|
||||||
lastName: data.lastName,
|
|
||||||
phone: data.phone
|
|
||||||
})
|
|
||||||
} else if (initialData?.id) {
|
|
||||||
// Admin update mode
|
|
||||||
result = await updateUser(initialData.id, {
|
|
||||||
firstName: data.firstName,
|
|
||||||
lastName: data.lastName,
|
|
||||||
email: data.email,
|
|
||||||
password: data.password,
|
|
||||||
role: data.role,
|
|
||||||
phone: data.phone
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
// Admin create mode
|
// Create
|
||||||
// Password requirement is now handled by Zod Schema
|
if (!data.password || data.password.length < 6) {
|
||||||
result = await createUser(data.firstName, data.lastName, data.email, data.password!, data.role, data.phone)
|
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) {
|
if (result.error) {
|
||||||
@@ -135,20 +95,10 @@ export function UserForm({ initialData, mode = "admin" }: UserFormProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(
|
toast.success(initialData ? "Kullanıcı güncellendi." : "Kullanıcı oluşturuldu.")
|
||||||
mode === "profile"
|
router.push("/dashboard/users")
|
||||||
? "Profil bilgileriniz güncellendi."
|
|
||||||
: initialData ? "Kullanıcı güncellendi." : "Kullanıcı oluşturuldu."
|
|
||||||
)
|
|
||||||
|
|
||||||
router.refresh()
|
router.refresh()
|
||||||
router.refresh()
|
} catch (error) {
|
||||||
if (returnTo) {
|
|
||||||
router.push(returnTo)
|
|
||||||
} else if (mode === "admin") {
|
|
||||||
router.push("/dashboard/users")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
toast.error("Bir sorun oluştu.")
|
toast.error("Bir sorun oluştu.")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -191,12 +141,12 @@ export function UserForm({ initialData, mode = "admin" }: UserFormProps) {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="phone"
|
name="email"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Telefon</FormLabel>
|
<FormLabel>E-posta</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<PhoneInput placeholder="555 123 4567" defaultCountry="TR" {...field} />
|
<Input placeholder="ahmet@parakasa.com" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -205,88 +155,43 @@ export function UserForm({ initialData, mode = "admin" }: UserFormProps) {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="email"
|
name="password"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>E-posta</FormLabel>
|
<FormLabel>Şifre</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input type="password" placeholder={initialData ? "Değiştirmek için yeni şifre girin" : "******"} {...field} />
|
||||||
placeholder="ahmet@parakasa.com"
|
|
||||||
{...field}
|
|
||||||
disabled={mode === "profile"}
|
|
||||||
className={mode === "profile" ? "bg-muted" : ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
{mode === "profile" && <p className="text-[0.8rem] text-muted-foreground">E-posta adresi değiştirilemez.</p>}
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{mode === "admin" && (
|
<FormField
|
||||||
<>
|
control={form.control}
|
||||||
<FormField
|
name="role"
|
||||||
control={form.control}
|
render={({ field }) => (
|
||||||
name="password"
|
<FormItem>
|
||||||
render={({ field }) => (
|
<FormLabel>Rol</FormLabel>
|
||||||
<FormItem>
|
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||||
<FormLabel>Şifre</FormLabel>
|
<FormControl>
|
||||||
<FormControl>
|
<SelectTrigger>
|
||||||
<Input type="password" placeholder={initialData ? "Değiştirmek için yeni şifre girin" : "******"} {...field} />
|
<SelectValue placeholder="Rol seçin" />
|
||||||
</FormControl>
|
</SelectTrigger>
|
||||||
<FormMessage />
|
</FormControl>
|
||||||
</FormItem>
|
<SelectContent>
|
||||||
)}
|
<SelectItem value="user">Kullanıcı</SelectItem>
|
||||||
/>
|
<SelectItem value="admin">Yönetici</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
<FormField
|
</Select>
|
||||||
control={form.control}
|
<FormMessage />
|
||||||
name="confirmPassword"
|
</FormItem>
|
||||||
render={({ field }) => (
|
)}
|
||||||
<FormItem>
|
/>
|
||||||
<FormLabel>Şifre Tekrar</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="password" placeholder="******" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="role"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Rol</FormLabel>
|
|
||||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Rol seçin" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="user">Kullanıcı</SelectItem>
|
|
||||||
<SelectItem value="admin">Yönetici</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mode === "profile" && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<FormLabel>Rol</FormLabel>
|
|
||||||
<Input value={initialData?.role === 'admin' ? 'Yönetici' : 'Kullanıcı'} disabled className="bg-muted" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button type="submit" disabled={loading} className="w-full">
|
<Button type="submit" disabled={loading} className="w-full">
|
||||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
{mode === "profile" ? "Değişiklikleri Kaydet" : (initialData ? "Kaydet" : "Kullanıcı Oluştur")}
|
Kullanıcı Oluştur
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -14,85 +14,61 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { createBrowserClient } from "@supabase/ssr"
|
import { supabase } from "@/lib/supabase"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
|
export function UserNav() {
|
||||||
interface UserNavProps {
|
|
||||||
user: {
|
|
||||||
email?: string | null
|
|
||||||
} | null
|
|
||||||
profile: {
|
|
||||||
full_name?: string | null
|
|
||||||
role?: string | null
|
|
||||||
} | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UserNav({ user, profile }: UserNavProps) {
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const supabase = createBrowserClient(
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
await supabase.auth.signOut()
|
await supabase.auth.signOut()
|
||||||
router.push("/")
|
router.push("/login")
|
||||||
router.refresh()
|
router.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
const getInitials = (name: string) => {
|
|
||||||
return name
|
|
||||||
.split(' ')
|
|
||||||
.map((n) => n[0])
|
|
||||||
.join('')
|
|
||||||
.toUpperCase()
|
|
||||||
.substring(0, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="relative h-9 w-9 rounded-full ring-2 ring-primary/10 ring-offset-2 hover:ring-primary/20 transition-all">
|
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
||||||
<Avatar className="h-9 w-9">
|
<Avatar className="h-8 w-8">
|
||||||
<AvatarImage src="" alt={profile?.full_name || "@parakasa"} />
|
<AvatarImage src="/avatars/01.png" alt="@parakasa" />
|
||||||
<AvatarFallback className="bg-gradient-to-br from-blue-600 to-indigo-600 text-white font-bold text-xs">
|
<AvatarFallback>PK</AvatarFallback>
|
||||||
{profile?.full_name
|
|
||||||
? getInitials(profile.full_name)
|
|
||||||
: user?.email
|
|
||||||
? user.email.substring(0, 2).toUpperCase()
|
|
||||||
: 'PK'}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-56" align="end">
|
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||||
<DropdownMenuLabel className="font-normal">
|
<DropdownMenuLabel className="font-normal">
|
||||||
<div className="flex flex-col space-y-1">
|
<div className="flex flex-col space-y-1">
|
||||||
<p className="text-sm font-medium leading-none">{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">
|
<p className="text-xs leading-none text-muted-foreground">
|
||||||
{user?.email}
|
admin@parakasa.com
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<Link href="/dashboard/profile">
|
||||||
<Link href="/dashboard/profile">
|
<DropdownMenuItem className="cursor-pointer">
|
||||||
Profil Bilgileri
|
Profil
|
||||||
</Link>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
</Link>
|
||||||
<DropdownMenuItem asChild className="cursor-pointer">
|
<Link href="/dashboard/users">
|
||||||
<Link href="/dashboard/profile/password">
|
<DropdownMenuItem className="cursor-pointer">
|
||||||
Şifre Değiştir
|
Kullanıcılar
|
||||||
</Link>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
</Link>
|
||||||
|
<Link href="/dashboard/settings">
|
||||||
|
<DropdownMenuItem className="cursor-pointer">
|
||||||
|
Ayarlar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</Link>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleSignOut} className="cursor-pointer text-red-600 focus:text-red-600">
|
<DropdownMenuItem onClick={handleSignOut}>
|
||||||
Çıkış
|
Çıkış Yap
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,15 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { getSiteContents } from "@/lib/data"
|
|
||||||
import { Instagram, Youtube } from "lucide-react"
|
|
||||||
import { FaTiktok } from "react-icons/fa"
|
|
||||||
|
|
||||||
export async function Footer() {
|
|
||||||
const siteSettings = await getSiteContents()
|
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer className="w-full border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-12 md:py-24 lg:py-32">
|
<footer className="w-full border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-12 md:py-24 lg:py-32">
|
||||||
<div className="container grid gap-8 px-4 md:px-6 lg:grid-cols-4">
|
<div className="container grid gap-8 px-4 md:px-6 lg:grid-cols-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Link href="/" className="flex items-center gap-2">
|
<Link href="/" className="flex items-center gap-2">
|
||||||
<span className="text-xl font-bold tracking-tighter">{siteSettings.site_title || "PARAKASA"}</span>
|
<span className="text-xl font-bold tracking-tighter">PARAKASA</span>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -49,36 +44,19 @@ export async function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h3 className="font-semibold">Bize Ulaşın</h3>
|
<h3 className="font-semibold">Bize Ulaşın</h3>
|
||||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
<p className="text-sm text-muted-foreground">
|
||||||
{siteSettings.contact_address || "İstanbul, Türkiye"}
|
İstanbul, Türkiye
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{siteSettings.contact_phone || "+90 212 000 00 00"}
|
+90 212 000 00 00
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{siteSettings.contact_email}
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-4 mt-4">
|
<div className="flex gap-4 mt-4">
|
||||||
{siteSettings.social_instagram && (
|
{/* Social Media Icons would go here */}
|
||||||
<Link href={siteSettings.social_instagram} target="_blank" className="text-muted-foreground hover:text-foreground">
|
|
||||||
<Instagram className="h-5 w-5" />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{siteSettings.social_youtube && (
|
|
||||||
<Link href={siteSettings.social_youtube} target="_blank" className="text-muted-foreground hover:text-foreground">
|
|
||||||
<Youtube className="h-5 w-5" />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{siteSettings.social_tiktok && (
|
|
||||||
<Link href={siteSettings.social_tiktok} target="_blank" className="text-muted-foreground hover:text-foreground">
|
|
||||||
<FaTiktok className="h-5 w-5" />
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="container px-4 md:px-6 mt-8 pt-8 border-t text-center text-sm text-muted-foreground">
|
<div className="container px-4 md:px-6 mt-8 pt-8 border-t text-center text-sm text-muted-foreground">
|
||||||
© {new Date().getFullYear()} {siteSettings.site_title || "ParaKasa"}. Tüm hakları saklıdır.
|
© 2026 ParaKasa. Tüm hakları saklıdır.
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,33 +4,19 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { createClient } from "@/lib/supabase-server"
|
import { createClient } from "@/lib/supabase-server"
|
||||||
import { getSiteContents } from "@/lib/data"
|
|
||||||
import Image from "next/image"
|
|
||||||
|
|
||||||
export async function Navbar() {
|
export async function Navbar() {
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
const siteSettings = await getSiteContents()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="container flex h-16 items-center">
|
<div className="container flex h-16 items-center">
|
||||||
<div className="mr-8 hidden md:flex">
|
<div className="mr-8 hidden md:flex">
|
||||||
<Link href="/" className="mr-6 flex items-center space-x-2">
|
<Link href="/" className="mr-6 flex items-center space-x-2">
|
||||||
{siteSettings.site_logo ? (
|
<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">
|
||||||
<div className="relative h-10 w-32">
|
PARAKASA
|
||||||
<Image
|
</span>
|
||||||
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"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center space-x-6 text-sm font-medium">
|
<nav className="flex items-center space-x-6 text-sm font-medium">
|
||||||
<Link
|
<Link
|
||||||
@@ -66,19 +52,8 @@ export async function Navbar() {
|
|||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="left" className="pr-0">
|
<SheetContent side="left" className="pr-0">
|
||||||
<Link href="/" className="flex items-center mb-6">
|
<Link href="/" className="flex items-center">
|
||||||
{siteSettings.site_logo ? (
|
<span className="font-bold">PARAKASA</span>
|
||||||
<div className="relative h-10 w-32">
|
|
||||||
<Image
|
|
||||||
src={siteSettings.site_logo}
|
|
||||||
alt={siteSettings.site_title || "ParaKasa"}
|
|
||||||
fill
|
|
||||||
className="object-contain object-left"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="font-bold text-xl">{siteSettings.site_title || "PARAKASA"}</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex flex-col gap-4 mt-8">
|
<div className="flex flex-col gap-4 mt-8">
|
||||||
<Link href="/products">Ürünler</Link>
|
<Link href="/products">Ürünler</Link>
|
||||||
@@ -88,26 +63,8 @@ export async function Navbar() {
|
|||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
{/* Mobile Logo (Visible only on mobile) */}
|
|
||||||
<Link href="/" className="mr-6 flex items-center space-x-2 md:hidden">
|
|
||||||
{siteSettings.site_logo ? (
|
|
||||||
<div className="relative h-8 w-24">
|
|
||||||
<Image
|
|
||||||
src={siteSettings.site_logo}
|
|
||||||
alt={siteSettings.site_title || "ParaKasa"}
|
|
||||||
fill
|
|
||||||
className="object-contain object-left"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-lg font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-slate-900 to-slate-500 dark:from-slate-100 dark:to-slate-400">
|
|
||||||
{siteSettings.site_title || "PARAKASA"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
||||||
<div className="w-full flex-1 md:w-auto md:flex-none hidden md:block">
|
<div className="w-full flex-1 md:w-auto md:flex-none">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean
|
asChild?: boolean
|
||||||
}>(({ className, variant = "default", size = "default", asChild = false, ...props }, ref) => {
|
}) {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
data-variant={variant}
|
||||||
ref={ref}
|
data-size={size}
|
||||||
{...props}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
/>
|
{...props}
|
||||||
)
|
/>
|
||||||
})
|
)
|
||||||
Button.displayName = "Button"
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants }
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
@@ -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 }
|
|
||||||
@@ -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 }
|
|
||||||
@@ -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 }
|
|
||||||
@@ -13,7 +13,7 @@ export async function submitContactForm(data: ContactFormValues) {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
// In a real app, you would use Resend or Nodemailer here
|
// In a real app, you would use Resend or Nodemailer here
|
||||||
|
console.log("Contact Form Submitted:", result.data)
|
||||||
|
|
||||||
return { success: true, message: "Mesajınız başarıyla gönderildi." }
|
return { success: true, message: "Mesajınız başarıyla gönderildi." }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
31
lib/data.ts
31
lib/data.ts
@@ -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
8
lib/site-settings.ts
Normal 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
|
||||||
|
})
|
||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
BIN
lint-results.txt
BIN
lint-results.txt
Binary file not shown.
@@ -1,4 +0,0 @@
|
|||||||
|
|
||||||
> parakasa@0.1.0 lint
|
|
||||||
> next lint
|
|
||||||
|
|
||||||
BIN
lint_output.txt
BIN
lint_output.txt
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
lint_report.txt
BIN
lint_report.txt
Binary file not shown.
@@ -1,5 +0,0 @@
|
|||||||
|
|
||||||
> parakasa@0.1.0 lint
|
|
||||||
> next lint
|
|
||||||
|
|
||||||
✔ No ESLint warnings or errors
|
|
||||||
8
make_admin.sql
Normal file
8
make_admin.sql
Normal 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';
|
||||||
@@ -38,19 +38,11 @@ export async function middleware(request: NextRequest) {
|
|||||||
} = await supabase.auth.getUser();
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
// Protected routes
|
// Protected routes
|
||||||
if (request.nextUrl.pathname.startsWith("/dashboard")) {
|
if (!user && request.nextUrl.pathname.startsWith("/dashboard")) {
|
||||||
if (!user) {
|
return NextResponse.redirect(new URL("/login", request.url));
|
||||||
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"))) {
|
if (user && (request.nextUrl.pathname.startsWith("/login") || request.nextUrl.pathname.startsWith("/signup"))) {
|
||||||
return NextResponse.redirect(new URL("/dashboard", request.url));
|
return NextResponse.redirect(new URL("/dashboard", request.url));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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')
|
|
||||||
);
|
|
||||||
@@ -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' );
|
|
||||||
|
|
||||||
@@ -1,18 +1,6 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
images: {
|
|
||||||
remotePatterns: [
|
|
||||||
{
|
|
||||||
protocol: 'https',
|
|
||||||
hostname: '**.supabase.co',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
protocol: 'https',
|
|
||||||
hostname: 'api.unsalcelikparakasalari.com',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
webpack: (config) => {
|
webpack: (config) => {
|
||||||
// Suppress cache serialization warnings
|
// Suppress cache serialization warnings
|
||||||
config.infrastructureLogging = {
|
config.infrastructureLogging = {
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
[phases.setup]
|
|
||||||
nixPkgs = ["nodejs"]
|
|
||||||
|
|
||||||
[phases.install]
|
|
||||||
cmds = ["npm ci"]
|
|
||||||
|
|
||||||
[phases.build]
|
|
||||||
cmds = ["npm run build"]
|
|
||||||
14
notlar.txt
14
notlar.txt
@@ -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ı açı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
526
package-lock.json
generated
@@ -10,20 +10,16 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
|
||||||
"@supabase/ssr": "^0.8.0",
|
"@supabase/ssr": "^0.8.0",
|
||||||
"@supabase/supabase-js": "^2.89.0",
|
"@supabase/supabase-js": "^2.89.0",
|
||||||
"browser-image-compression": "^2.0.2",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -34,26 +30,20 @@
|
|||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-hook-form": "^7.70.0",
|
"react-hook-form": "^7.70.0",
|
||||||
"react-icons": "^5.5.0",
|
|
||||||
"react-phone-number-input": "^3.4.14",
|
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.16.0",
|
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"dotenv": "^17.2.3",
|
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.2.16",
|
"eslint-config-next": "14.2.16",
|
||||||
"pg": "^8.17.2",
|
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
@@ -71,30 +61,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cspotcode/source-map-support": {
|
|
||||||
"version": "0.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
|
||||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@jridgewell/trace-mapping": "0.3.9"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
|
|
||||||
"version": "0.3.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
|
||||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@jridgewell/resolve-uri": "^3.0.3",
|
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.7.1",
|
"version": "1.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
|
||||||
@@ -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": {
|
"node_modules/@radix-ui/react-collection": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||||
@@ -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": {
|
"node_modules/@radix-ui/react-select": {
|
||||||
"version": "2.2.6",
|
"version": "2.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
||||||
@@ -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": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
@@ -1803,34 +1678,6 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tsconfig/node10": {
|
|
||||||
"version": "1.0.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
|
||||||
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@tsconfig/node12": {
|
|
||||||
"version": "1.0.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
|
||||||
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@tsconfig/node14": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@tsconfig/node16": {
|
|
||||||
"version": "1.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
|
||||||
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -1858,18 +1705,6 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/pg": {
|
|
||||||
"version": "8.16.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz",
|
|
||||||
"integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*",
|
|
||||||
"pg-protocol": "*",
|
|
||||||
"pg-types": "^2.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/phoenix": {
|
"node_modules/@types/phoenix": {
|
||||||
"version": "1.6.7",
|
"version": "1.6.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
|
||||||
@@ -2481,19 +2316,6 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/acorn-walk": {
|
|
||||||
"version": "8.3.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
|
|
||||||
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"acorn": "^8.11.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
@@ -2898,15 +2720,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/browser-image-compression": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"uzip": "0.20201231.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.1",
|
"version": "4.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||||
@@ -3109,12 +2922,6 @@
|
|||||||
"url": "https://polar.sh/cva"
|
"url": "https://polar.sh/cva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/classnames": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/client-only": {
|
"node_modules/client-only": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||||
@@ -3180,19 +2987,6 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/country-flag-icons": {
|
|
||||||
"version": "1.6.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.6.4.tgz",
|
|
||||||
"integrity": "sha512-Z3Zi419FI889tlElMsVhCIS5eRkiLDWixr576J5DPiTe5RGxpbRi+enMpHdYVp5iK5WFjr8P/RgyIFAGhFsiFg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/create-require": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -3373,16 +3167,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/diff": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.3.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dlv": {
|
"node_modules/dlv": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||||
@@ -3403,19 +3187,6 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dotenv": {
|
|
||||||
"version": "17.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
|
||||||
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://dotenvx.com"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -4733,27 +4504,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/input-format": {
|
|
||||||
"version": "0.3.14",
|
|
||||||
"resolved": "https://registry.npmjs.org/input-format/-/input-format-0.3.14.tgz",
|
|
||||||
"integrity": "sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"prop-types": "^15.8.1"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=18.1.0",
|
|
||||||
"react-dom": ">=18.1.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||||
@@ -5378,12 +5128,6 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/libphonenumber-js": {
|
|
||||||
"version": "1.12.33",
|
|
||||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.33.tgz",
|
|
||||||
"integrity": "sha512-r9kw4OA6oDO4dPXkOrXTkArQAafIKAU71hChInV4FxZ69dxCfbwQGDPzqR5/vea94wU705/3AZroEbSoeVWrQw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lilconfig": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||||
@@ -5455,13 +5199,6 @@
|
|||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/make-error": {
|
|
||||||
"version": "1.3.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
|
||||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -5699,6 +5436,7 @@
|
|||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -5972,103 +5710,6 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pg": {
|
|
||||||
"version": "8.17.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.17.2.tgz",
|
|
||||||
"integrity": "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"pg-connection-string": "^2.10.1",
|
|
||||||
"pg-pool": "^3.11.0",
|
|
||||||
"pg-protocol": "^1.11.0",
|
|
||||||
"pg-types": "2.2.0",
|
|
||||||
"pgpass": "1.0.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 16.0.0"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"pg-cloudflare": "^1.3.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"pg-native": ">=3.0.1"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"pg-native": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pg-cloudflare": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"node_modules/pg-connection-string": {
|
|
||||||
"version": "2.10.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.10.1.tgz",
|
|
||||||
"integrity": "sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/pg-int8": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pg-pool": {
|
|
||||||
"version": "3.11.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
|
|
||||||
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"pg": ">=8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pg-protocol": {
|
|
||||||
"version": "1.11.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
|
|
||||||
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/pg-types": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"pg-int8": "1.0.1",
|
|
||||||
"postgres-array": "~2.0.0",
|
|
||||||
"postgres-bytea": "~1.0.0",
|
|
||||||
"postgres-date": "~1.0.4",
|
|
||||||
"postgres-interval": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pgpass": {
|
|
||||||
"version": "1.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
|
||||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"split2": "^4.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -6281,49 +5922,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/postgres-array": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/postgres-bytea": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/postgres-date": {
|
|
||||||
"version": "1.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
|
||||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/postgres-interval": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"xtend": "^4.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -6338,6 +5936,7 @@
|
|||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
@@ -6417,38 +6016,13 @@
|
|||||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-icons": {
|
|
||||||
"version": "5.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
|
||||||
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-phone-number-input": {
|
|
||||||
"version": "3.4.14",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-phone-number-input/-/react-phone-number-input-3.4.14.tgz",
|
|
||||||
"integrity": "sha512-T9MziNuvthzv6+JAhKD71ab/jVXW5U20nQZRBJd6+q+ujmkC+/ISOf2GYo8pIi4VGjdIYRIHDftMAYn3WKZT3w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"classnames": "^2.5.1",
|
|
||||||
"country-flag-icons": "^1.5.17",
|
|
||||||
"input-format": "^0.3.14",
|
|
||||||
"libphonenumber-js": "^1.12.27",
|
|
||||||
"prop-types": "^15.8.1"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.8",
|
|
||||||
"react-dom": ">=16.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-remove-scroll": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
@@ -6957,16 +6531,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/split2": {
|
|
||||||
"version": "4.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
|
||||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/stable-hash": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||||
@@ -7470,57 +7034,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/ts-node": {
|
|
||||||
"version": "10.9.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
|
||||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@cspotcode/source-map-support": "^0.8.0",
|
|
||||||
"@tsconfig/node10": "^1.0.7",
|
|
||||||
"@tsconfig/node12": "^1.0.7",
|
|
||||||
"@tsconfig/node14": "^1.0.0",
|
|
||||||
"@tsconfig/node16": "^1.0.2",
|
|
||||||
"acorn": "^8.4.1",
|
|
||||||
"acorn-walk": "^8.1.1",
|
|
||||||
"arg": "^4.1.0",
|
|
||||||
"create-require": "^1.1.0",
|
|
||||||
"diff": "^4.0.1",
|
|
||||||
"make-error": "^1.1.1",
|
|
||||||
"v8-compile-cache-lib": "^3.0.1",
|
|
||||||
"yn": "3.1.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"ts-node": "dist/bin.js",
|
|
||||||
"ts-node-cwd": "dist/bin-cwd.js",
|
|
||||||
"ts-node-esm": "dist/bin-esm.js",
|
|
||||||
"ts-node-script": "dist/bin-script.js",
|
|
||||||
"ts-node-transpile-only": "dist/bin-transpile.js",
|
|
||||||
"ts-script": "dist/bin-script-deprecated.js"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@swc/core": ">=1.2.50",
|
|
||||||
"@swc/wasm": ">=1.2.50",
|
|
||||||
"@types/node": "*",
|
|
||||||
"typescript": ">=2.7"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@swc/core": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@swc/wasm": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ts-node/node_modules/arg": {
|
|
||||||
"version": "4.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
|
||||||
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/tsconfig-paths": {
|
"node_modules/tsconfig-paths": {
|
||||||
"version": "3.15.0",
|
"version": "3.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
||||||
@@ -7828,19 +7341,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/uzip": {
|
|
||||||
"version": "0.20201231.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz",
|
|
||||||
"integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/v8-compile-cache-lib": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -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": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
17
package.json
17
package.json
@@ -6,28 +6,21 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "eslint"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20.x"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
|
||||||
"@supabase/ssr": "^0.8.0",
|
"@supabase/ssr": "^0.8.0",
|
||||||
"@supabase/supabase-js": "^2.89.0",
|
"@supabase/supabase-js": "^2.89.0",
|
||||||
"browser-image-compression": "^2.0.2",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -38,27 +31,21 @@
|
|||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-hook-form": "^7.70.0",
|
"react-hook-form": "^7.70.0",
|
||||||
"react-icons": "^5.5.0",
|
|
||||||
"react-phone-number-input": "^3.4.14",
|
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.16.0",
|
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"dotenv": "^17.2.3",
|
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.2.16",
|
"eslint-config-next": "14.2.16",
|
||||||
"pg": "^8.17.2",
|
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
Reference in New Issue
Block a user