Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 215788f858 | |||
| d14b293bd8 | |||
| d320787df6 | |||
| 5fdb7592e7 | |||
| aec7396030 | |||
| 56cae7a56f | |||
| 54113726f4 | |||
| 10bfa3089e | |||
| fde1c84ecb | |||
| efddf97c01 | |||
| 5c34df0f09 | |||
| 1e1baa84ff | |||
| a7ef9bd899 | |||
| 6992891ae3 | |||
| 0fe49b5c96 | |||
| 6e56b1e75f | |||
| 759bf2fbdd | |||
| 70f61a76b0 | |||
| 6bbae0de21 | |||
| 8eae770349 | |||
| dc1b6f1359 | |||
| 32009b4886 | |||
| b2a915240f | |||
| e80c8fd74b | |||
| 9c9e1ed9de | |||
| a41da2286f |
6
.eslintrc.json
Normal file
6
.eslintrc.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"next/core-web-vitals",
|
||||||
|
"next/typescript"
|
||||||
|
]
|
||||||
|
}
|
||||||
11
PLANLAMA.md
11
PLANLAMA.md
@@ -17,7 +17,16 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Mevcut Durum (Tamamlananlar)
|
## Mevcut Durum (Tamamlananlar)
|
||||||
- [x] Kullanıcı Yönetimi (Admin Ekle/Sil).
|
- [x] Kullanıcı Yönetimi (Admin Ekle/Sil/Düzenle + Telefon).
|
||||||
- [x] Temel Site Ayarları (Başlık, İletişim).
|
- [x] Temel Site Ayarları (Başlık, İletişim).
|
||||||
- [x] Ürün Yönetimi (Temel CRUD).
|
- [x] Ürün Yönetimi (Temel CRUD).
|
||||||
- [x] Kategori Yönetimi (Arayüz hazır, veritabanı bekleniyor).
|
- [x] Kategori Yönetimi (Arayüz hazır, veritabanı bekleniyor).
|
||||||
|
|
||||||
|
## 3. Tamamlanan Ek Özellikler (28.01.2026)
|
||||||
|
- [x] **NetGSM Entegrasyonu:** Login için SMS doğrulama (2FA) eklendi.
|
||||||
|
- [x] **Oto-Çıkış:** 15dk hareketsizlikte otomatik çıkış.
|
||||||
|
- [x] **Ürün Geliştirmeleri:**
|
||||||
|
- Aktif/Pasif durumu.
|
||||||
|
- Çoklu resim yükleme.
|
||||||
|
- Resim optimizasyonu.
|
||||||
|
-
|
||||||
45
app/(dashboard)/dashboard/cms/content/actions.ts
Normal file
45
app/(dashboard)/dashboard/cms/content/actions.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { createClient } from "@/lib/supabase-server"
|
||||||
|
import { SiteContent } from "@/types/cms"
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
|
||||||
|
export async function updateSiteContent(contents: SiteContent[]) {
|
||||||
|
const supabase = await createClient()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { success: false, error: "Oturum açmanız gerekiyor" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert each content item
|
||||||
|
// Since we might have many items, we can do this in parallel or a single upsert if the structure allows
|
||||||
|
// Supabase upsert accepts an array
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('site_contents')
|
||||||
|
.upsert(
|
||||||
|
contents.map(item => ({
|
||||||
|
key: item.key,
|
||||||
|
value: item.value,
|
||||||
|
type: item.type,
|
||||||
|
section: item.section,
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('CMS Update Error:', error)
|
||||||
|
return { success: false, error: "Güncelleme sırasında bir hata oluştu: " + error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/dashboard/cms/content')
|
||||||
|
revalidatePath('/') // Revalidate home page as it likely uses these settings
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('CMS Update Error:', error)
|
||||||
|
return { success: false, error: "Bir hata oluştu" }
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/(dashboard)/dashboard/cms/content/page.tsx
Normal file
61
app/(dashboard)/dashboard/cms/content/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { createClient } from "@/lib/supabase-server"
|
||||||
|
import { ContentForm } from "@/components/dashboard/content-form"
|
||||||
|
import { SiteContent } from "@/types/cms"
|
||||||
|
|
||||||
|
export default async function ContentPage() {
|
||||||
|
const supabase = await createClient()
|
||||||
|
|
||||||
|
const { data: contents } = await supabase
|
||||||
|
.from('site_contents')
|
||||||
|
.select('*')
|
||||||
|
.order('key')
|
||||||
|
|
||||||
|
// Define default contents that should exist
|
||||||
|
const DEFAULT_CONTENTS: SiteContent[] = [
|
||||||
|
// General
|
||||||
|
{ key: 'site_title', value: 'ParaKasa', type: 'text', section: 'general' },
|
||||||
|
{ key: 'site_description', value: '', type: 'long_text', section: 'general' },
|
||||||
|
{ key: 'site_logo', value: '', type: 'image_url', section: 'general' },
|
||||||
|
|
||||||
|
// Contact
|
||||||
|
{ key: 'contact_phone', value: '', type: 'text', section: 'contact' },
|
||||||
|
{ key: 'contact_email', value: '', type: 'text', section: 'contact' },
|
||||||
|
{ key: 'contact_address', value: '', type: 'long_text', section: 'contact' },
|
||||||
|
{ key: 'social_instagram', value: '', type: 'text', section: 'contact' },
|
||||||
|
{ key: 'social_youtube', value: '', type: 'text', section: 'contact' },
|
||||||
|
{ key: 'social_tiktok', value: '', type: 'text', section: 'contact' },
|
||||||
|
{ key: 'contact_map_embed', value: '', type: 'html', section: 'contact' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Merge default contents with existing contents
|
||||||
|
const mergedContents = [...(contents as SiteContent[] || [])]
|
||||||
|
const existingKeys = new Set(mergedContents.map(c => c.key))
|
||||||
|
|
||||||
|
DEFAULT_CONTENTS.forEach(item => {
|
||||||
|
if (!existingKeys.has(item.key)) {
|
||||||
|
mergedContents.push(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">İçerik Yönetimi</h2>
|
||||||
|
</div>
|
||||||
|
<div className="hidden h-full flex-1 flex-col space-y-8 md:flex">
|
||||||
|
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-1">
|
||||||
|
<div className="col-span-1">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Site başlığı, sloganlar, iletişim bilgileri ve logoları buradan yönetebilirsiniz.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ContentForm initialContent={mergedContents} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
app/(dashboard)/dashboard/customers/page.tsx
Normal file
76
app/(dashboard)/dashboard/customers/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { getCustomers, deleteCustomer } from "@/lib/customers/actions"
|
||||||
|
import { CustomerForm } from "@/components/dashboard/customer-form"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Trash, Edit } from "lucide-react"
|
||||||
|
|
||||||
|
export default async function CustomersPage() {
|
||||||
|
const { data: customers } = await getCustomers()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Müşteriler</h2>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<CustomerForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Ad Soyad</TableHead>
|
||||||
|
<TableHead>E-Posta</TableHead>
|
||||||
|
<TableHead>Telefon</TableHead>
|
||||||
|
<TableHead>Adres</TableHead>
|
||||||
|
<TableHead className="w-[100px]">İşlemler</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{customers?.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="h-24 text-center">
|
||||||
|
Henüz müşteri bulunmuyor.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{customers?.map((customer) => (
|
||||||
|
<TableRow key={customer.id}>
|
||||||
|
<TableCell className="font-medium">{customer.full_name}</TableCell>
|
||||||
|
<TableCell>{customer.email || "-"}</TableCell>
|
||||||
|
<TableCell>{customer.phone || "-"}</TableCell>
|
||||||
|
<TableCell className="truncate max-w-[200px]">{customer.address || "-"}</TableCell>
|
||||||
|
<TableCell className="flex items-center gap-2">
|
||||||
|
<CustomerForm
|
||||||
|
customer={customer}
|
||||||
|
trigger={
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<form action={async () => {
|
||||||
|
'use server'
|
||||||
|
await deleteCustomer(customer.id)
|
||||||
|
}}>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive">
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createClient } from "@/lib/supabase-server"
|
import { createClient } from "@/lib/supabase-server"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { DollarSign, ShoppingCart, Users, CreditCard, Package } from "lucide-react"
|
import { DollarSign, ShoppingCart, Users, Package } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ export default async function DashboardPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlusIcon(props: any) {
|
function PlusIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -3,49 +3,97 @@
|
|||||||
import { createClient } from "@/lib/supabase-server"
|
import { createClient } from "@/lib/supabase-server"
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
|
|
||||||
export async function createProduct(data: any) {
|
interface ProductData {
|
||||||
|
name: string
|
||||||
|
category: string
|
||||||
|
description?: string
|
||||||
|
price: number
|
||||||
|
image_url?: string
|
||||||
|
is_active?: boolean
|
||||||
|
images?: string[]
|
||||||
|
product_code?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProduct(data: ProductData) {
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
|
|
||||||
// Validate data manually or use Zod schema here again securely
|
|
||||||
// For simplicity, we assume data is coming from the strongly typed Client Form
|
|
||||||
// In production, ALWAYS validate server-side strictly.
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase.from("products").insert({
|
// 1. Create Product
|
||||||
|
const { data: product, error } = await supabase.from("products").insert({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
category: data.category,
|
category: data.category,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
price: data.price,
|
price: data.price,
|
||||||
image_url: data.image_url,
|
image_url: data.image_url, // Main image (can be first of images)
|
||||||
})
|
is_active: data.is_active ?? true,
|
||||||
|
product_code: data.product_code
|
||||||
|
}).select().single()
|
||||||
|
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
|
|
||||||
revalidatePath("/dashboard/products")
|
// 2. Insert Images (if any)
|
||||||
return { success: true }
|
if (data.images && data.images.length > 0) {
|
||||||
} catch (error: any) {
|
const imageInserts = data.images.map((url, index) => ({
|
||||||
return { success: false, error: error.message }
|
product_id: product.id,
|
||||||
|
image_url: url,
|
||||||
|
display_order: index
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { error: imgError } = await supabase.from("product_images").insert(imageInserts)
|
||||||
|
if (imgError) {
|
||||||
|
console.error("Error inserting images:", imgError)
|
||||||
|
// We don't throw here to avoid failing the whole product creation if just images fail
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProduct(id: number, data: any) {
|
revalidatePath("/dashboard/products")
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProduct(id: number, data: ProductData) {
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 1. Update Product
|
||||||
const { error } = await supabase.from("products").update({
|
const { error } = await supabase.from("products").update({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
category: data.category,
|
category: data.category,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
price: data.price,
|
price: data.price,
|
||||||
image_url: data.image_url,
|
image_url: data.image_url,
|
||||||
|
is_active: data.is_active,
|
||||||
|
product_code: data.product_code
|
||||||
}).eq("id", id)
|
}).eq("id", id)
|
||||||
|
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
|
|
||||||
|
// 2. Update Images
|
||||||
|
// Strategy: Delete all and re-insert is simplest for now.
|
||||||
|
// Or better: Differential update. For simplicity in MVP: Delete all for this product and re-insert *if* new images provided.
|
||||||
|
// Actually, if we want to keep existing ones, we need more complex logic.
|
||||||
|
// For now, let's assume the form sends the FULL list of current images.
|
||||||
|
if (data.images) {
|
||||||
|
// Delete old
|
||||||
|
await supabase.from("product_images").delete().eq("product_id", id)
|
||||||
|
|
||||||
|
// Insert new
|
||||||
|
if (data.images.length > 0) {
|
||||||
|
const imageInserts = data.images.map((url, index) => ({
|
||||||
|
product_id: id,
|
||||||
|
image_url: url,
|
||||||
|
display_order: index
|
||||||
|
}))
|
||||||
|
await supabase.from("product_images").insert(imageInserts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath("/dashboard/products")
|
revalidatePath("/dashboard/products")
|
||||||
revalidatePath(`/dashboard/products/${id}`)
|
revalidatePath(`/dashboard/products/${id}`)
|
||||||
return { success: true }
|
return { success: true }
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
return { success: false, error: error.message }
|
return { success: false, error: (error as Error).message }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default async function ProductsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border rounded-md">
|
<div className="border rounded-md overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|||||||
@@ -1,14 +1,48 @@
|
|||||||
import { createClient } from "@/lib/supabase-server"
|
import { createClient } from "@/lib/supabase-server"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import { Button } from "@/components/ui/button"
|
import { UserForm } from "@/components/dashboard/user-form"
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
import { getProfile } from "@/lib/data"
|
||||||
|
|
||||||
export default async function ProfilePage() {
|
export default async function ProfilePage() {
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
// Should be protected by middleware but just in case
|
||||||
|
return <div>Lütfen giriş yapın.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch profile data
|
||||||
|
const profile = await getProfile(user.id)
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
// Fallback for user without profile row?
|
||||||
|
// Or create one on the fly?
|
||||||
|
return <div>Profil verisi bulunamadı.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Improved name parsing logic
|
||||||
|
const fullName = (profile.full_name || "").trim()
|
||||||
|
const firstSpaceIndex = fullName.indexOf(' ')
|
||||||
|
|
||||||
|
let firstName = fullName
|
||||||
|
let lastName = ""
|
||||||
|
|
||||||
|
if (firstSpaceIndex > 0) {
|
||||||
|
firstName = fullName.substring(0, firstSpaceIndex)
|
||||||
|
lastName = fullName.substring(firstSpaceIndex + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialData = {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
phone: profile.phone || "",
|
||||||
|
email: user.email || "",
|
||||||
|
role: profile.role || "user"
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-4 p-8 pt-6">
|
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||||
<div className="flex items-center justify-between space-y-2">
|
<div className="flex items-center justify-between space-y-2">
|
||||||
@@ -20,28 +54,18 @@ export default async function ProfilePage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Genel Bilgiler</CardTitle>
|
<CardTitle>Genel Bilgiler</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Kişisel profil bilgileriniz.
|
Kişisel profil bilgilerinizi buradan güncelleyebilirsiniz.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4 mb-6">
|
||||||
<Avatar className="h-20 w-20">
|
<Avatar className="h-20 w-20">
|
||||||
<AvatarImage src="/avatars/01.png" alt="@parakasa" />
|
<AvatarImage src="/avatars/01.png" alt="@parakasa" />
|
||||||
<AvatarFallback>PK</AvatarFallback>
|
<AvatarFallback>PK</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Button variant="outline">Fotoğraf Değiştir</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<UserForm initialData={initialData} mode="profile" />
|
||||||
<Label htmlFor="email">E-posta</Label>
|
|
||||||
<Input id="email" value={user?.email || ""} disabled />
|
|
||||||
<p className="text-xs text-muted-foreground">E-posta adresi değiştirilemez.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="role">Rol</Label>
|
|
||||||
<Input id="role" value={user?.role === 'authenticated' ? 'Yönetici' : 'Kullanıcı'} disabled />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
15
app/(dashboard)/dashboard/profile/password/page.tsx
Normal file
15
app/(dashboard)/dashboard/profile/password/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
import { PasswordForm } from "../../../../../components/dashboard/password-form"
|
||||||
|
|
||||||
|
export default function ChangePasswordPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Şifre Değiştir</h2>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-md">
|
||||||
|
<PasswordForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"use server"
|
|
||||||
|
|
||||||
import { createClient } from "@/lib/supabase-server"
|
|
||||||
import { revalidatePath } from "next/cache"
|
|
||||||
|
|
||||||
export async function updateSiteSettings(data: {
|
|
||||||
site_title: string
|
|
||||||
site_description: string
|
|
||||||
contact_email: string
|
|
||||||
contact_phone: string
|
|
||||||
currency: string
|
|
||||||
}) {
|
|
||||||
const supabase = createClient()
|
|
||||||
|
|
||||||
// Check admin is already handled by RLS on database level, but we can double check here
|
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
|
||||||
if (!user) return { error: "Oturum açmanız gerekiyor." }
|
|
||||||
|
|
||||||
// We update the single row where id is likely 1 or just the first row
|
|
||||||
// Since we initialized it with one row, we can just update match on something true or fetch id first.
|
|
||||||
// Easier: Update all rows (there should only be one) or fetch the specific ID first.
|
|
||||||
|
|
||||||
// Let's first get the ID just to be precise
|
|
||||||
const { data: existing } = await supabase.from('site_settings').select('id').single()
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
return { error: "Ayarlar bulunamadı." }
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('site_settings')
|
|
||||||
.update(data)
|
|
||||||
.eq('id', existing.id)
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return { error: "Ayarlar güncellenemedi: " + error.message }
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath("/dashboard/settings")
|
|
||||||
revalidatePath("/") // Revalidate home as it might use these settings
|
|
||||||
return { success: true }
|
|
||||||
}
|
|
||||||
@@ -1,45 +1,79 @@
|
|||||||
import { createClient } from "@/lib/supabase-server"
|
import { createClient } from "@/lib/supabase-server"
|
||||||
import { SiteSettingsForm } from "@/components/dashboard/site-settings-form"
|
import { SettingsTabs } from "@/components/dashboard/settings-tabs"
|
||||||
import { AppearanceForm } from "@/components/dashboard/appearance-form"
|
import { getSmsSettings } from "@/lib/sms/actions"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { SiteContent } from "@/types/cms"
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
|
|
||||||
// Fetch site settings
|
// Fetch SMS settings
|
||||||
const { data: settings } = await supabase
|
const smsResponse = await getSmsSettings()
|
||||||
.from('site_settings')
|
const smsSettings = smsResponse.data || null
|
||||||
|
|
||||||
|
// Fetch Users (Profiles)
|
||||||
|
const { data: profiles } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("*")
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
|
||||||
|
// Fetch Site Contents (CMS)
|
||||||
|
const { data: contents } = await supabase
|
||||||
|
.from('site_contents')
|
||||||
.select('*')
|
.select('*')
|
||||||
.single()
|
.order('key')
|
||||||
|
|
||||||
|
// Define default contents for CMS
|
||||||
|
const DEFAULT_CONTENTS: SiteContent[] = [
|
||||||
|
// General
|
||||||
|
{ key: 'site_title', value: 'ParaKasa', type: 'text', section: 'general' },
|
||||||
|
{ key: 'site_description', value: '', type: 'long_text', section: 'general' },
|
||||||
|
{ key: 'site_logo', value: '', type: 'image_url', section: 'general' },
|
||||||
|
{ key: 'favicon_url', value: '', type: 'image_url', section: 'general' },
|
||||||
|
|
||||||
|
// Home
|
||||||
|
{ key: 'home_hero_title', value: 'GÜVENLİK SINIR <br /> <span class="text-transparent bg-clip-text bg-gradient-to-r from-slate-200 to-slate-500">TANIMAZ</span>', type: 'html', section: 'home' },
|
||||||
|
{ key: 'home_hero_description', value: 'En değerli varlıklarınız için tasarlanmış yüksek güvenlikli çelik kasalar. Modern teknoloji ve zanaatkarlığın mükemmel uyumu.', type: 'long_text', section: 'home' },
|
||||||
|
{ key: 'home_hero_button_text', value: 'Koleksiyonu İncele', type: 'text', section: 'home' },
|
||||||
|
{ key: 'home_hero_bg_image', value: '/images/hero-safe.png', type: 'image_url', section: 'home' },
|
||||||
|
{ key: 'home_categories_title', value: 'Ürün Kategorileri', type: 'text', section: 'home' },
|
||||||
|
{ key: 'home_categories_description', value: 'İhtiyacınıza uygun güvenlik çözümünü seçin.', type: 'text', section: 'home' },
|
||||||
|
|
||||||
|
// Contact
|
||||||
|
{ key: 'contact_phone', value: '', type: 'text', section: 'contact' },
|
||||||
|
{ key: 'contact_email', value: '', type: 'text', section: 'contact' },
|
||||||
|
{ key: 'contact_address', value: '', type: 'long_text', section: 'contact' },
|
||||||
|
{ key: 'social_instagram', value: '', type: 'text', section: 'contact' },
|
||||||
|
{ key: 'social_youtube', value: '', type: 'text', section: 'contact' },
|
||||||
|
{ key: 'social_tiktok', value: '', type: 'text', section: 'contact' },
|
||||||
|
{ key: 'contact_map_embed', value: '', type: 'html', section: 'contact' },
|
||||||
|
|
||||||
|
// SEO
|
||||||
|
{ key: 'meta_keywords', value: '', type: 'text', section: 'seo' },
|
||||||
|
{ key: 'meta_author', value: '', type: 'text', section: 'seo' },
|
||||||
|
|
||||||
|
// Scripts & Analytics
|
||||||
|
{ key: 'google_analytics_id', value: '', type: 'text', section: 'scripts' },
|
||||||
|
{ key: 'facebook_pixel_id', value: '', type: 'text', section: 'scripts' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Merge default contents with existing contents
|
||||||
|
const mergedContents = [...(contents as SiteContent[] || [])]
|
||||||
|
const existingKeys = new Set(mergedContents.map(c => c.key))
|
||||||
|
|
||||||
|
DEFAULT_CONTENTS.forEach(item => {
|
||||||
|
if (!existingKeys.has(item.key)) {
|
||||||
|
mergedContents.push(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-4 p-8 pt-6">
|
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||||
<h2 className="text-3xl font-bold tracking-tight">Ayarlar</h2>
|
<h2 className="text-3xl font-bold tracking-tight">Ayarlar</h2>
|
||||||
|
<SettingsTabs
|
||||||
{/* Site General Settings */}
|
smsSettings={smsSettings}
|
||||||
<div className="grid gap-4">
|
users={profiles || []}
|
||||||
<SiteSettingsForm initialData={settings} />
|
contents={mergedContents}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2">
|
|
||||||
<AppearanceForm />
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Hesap Güvenliği</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Şifre ve oturum yönetimi.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<Button variant="outline" className="w-full">Şifre Değiştir</Button>
|
|
||||||
<Button variant="destructive" className="w-full">Hesabı Sil</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
34
app/(dashboard)/dashboard/sliders/[id]/page.tsx
Normal file
34
app/(dashboard)/dashboard/sliders/[id]/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { SliderForm } from "@/components/dashboard/slider-form"
|
||||||
|
import { createClient } from "@/lib/supabase-server"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
interface EditSliderPageProps {
|
||||||
|
params: {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EditSliderPage({ params }: EditSliderPageProps) {
|
||||||
|
const supabase = createClient()
|
||||||
|
|
||||||
|
const { data: slider } = await supabase
|
||||||
|
.from('sliders')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', params.id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (!slider) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Slider Düzenle</h2>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<SliderForm initialData={slider} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
app/(dashboard)/dashboard/sliders/actions.ts
Normal file
124
app/(dashboard)/dashboard/sliders/actions.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { createClient } from "@/lib/supabase-server"
|
||||||
|
import { createClient as createSupabaseClient } from "@supabase/supabase-js"
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
|
||||||
|
// Admin client for privileged operations
|
||||||
|
const supabaseAdmin = createSupabaseClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
||||||
|
{
|
||||||
|
auth: {
|
||||||
|
autoRefreshToken: false,
|
||||||
|
persistSession: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async function assertAdmin() {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
if (!user) throw new Error("Oturum açmanız gerekiyor.")
|
||||||
|
|
||||||
|
const { data: profile } = await supabase.from('profiles').select('role').eq('id', user.id).single()
|
||||||
|
if (profile?.role !== 'admin') throw new Error("Yetkisiz işlem.")
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSliders() {
|
||||||
|
const supabase = createClient()
|
||||||
|
// Everyone can read, so normal client is fine
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('sliders')
|
||||||
|
.select('*')
|
||||||
|
.order('order', { ascending: true })
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
|
if (error) return { error: error.message }
|
||||||
|
return { data }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSlider(data: {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
image_url: string
|
||||||
|
link?: string
|
||||||
|
order?: number
|
||||||
|
is_active?: boolean
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
await assertAdmin()
|
||||||
|
|
||||||
|
const { error } = await supabaseAdmin.from('sliders').insert({
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
image_url: data.image_url,
|
||||||
|
link: data.link,
|
||||||
|
order: data.order || 0,
|
||||||
|
is_active: data.is_active ?? true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/sliders")
|
||||||
|
revalidatePath("/") // Homepage cache update
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return { error: (error as Error).message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSlider(id: string, data: {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
image_url: string
|
||||||
|
link?: string
|
||||||
|
order?: number
|
||||||
|
is_active?: boolean
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
await assertAdmin()
|
||||||
|
|
||||||
|
const { error } = await supabaseAdmin
|
||||||
|
.from('sliders')
|
||||||
|
.update({
|
||||||
|
title: data.title,
|
||||||
|
description: data.description,
|
||||||
|
image_url: data.image_url,
|
||||||
|
link: data.link,
|
||||||
|
order: data.order,
|
||||||
|
is_active: data.is_active,
|
||||||
|
// updated_at trigger usually handles time, but we don't have it in schema yet, so maybe add later
|
||||||
|
})
|
||||||
|
.eq('id', id)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/sliders")
|
||||||
|
revalidatePath("/")
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return { error: (error as Error).message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSlider(id: string) {
|
||||||
|
try {
|
||||||
|
await assertAdmin()
|
||||||
|
|
||||||
|
const { error } = await supabaseAdmin
|
||||||
|
.from('sliders')
|
||||||
|
.delete()
|
||||||
|
.eq('id', id)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/sliders")
|
||||||
|
revalidatePath("/")
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return { error: (error as Error).message }
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/(dashboard)/dashboard/sliders/new/page.tsx
Normal file
14
app/(dashboard)/dashboard/sliders/new/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { SliderForm } from "@/components/dashboard/slider-form"
|
||||||
|
|
||||||
|
export default function NewSliderPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Yeni Slider Oluştur</h2>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<SliderForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
app/(dashboard)/dashboard/sliders/page.tsx
Normal file
85
app/(dashboard)/dashboard/sliders/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Plus, Pencil, GripVertical } from "lucide-react"
|
||||||
|
import { getSliders } from "./actions"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
||||||
|
export default async function SlidersPage() {
|
||||||
|
const { data: sliders, error } = await getSliders()
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="p-8 text-red-500">Hata: {error}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Slider Yönetimi</h2>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Link href="/dashboard/sliders/new">
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> Yeni Slider Ekle
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{sliders?.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center p-12 text-muted-foreground">
|
||||||
|
<p>Henüz hiç slider eklenmemiş.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
sliders?.map((slider) => (
|
||||||
|
<Card key={slider.id} className="overflow-hidden">
|
||||||
|
<div className="flex flex-col sm:flex-row items-center p-2 gap-4">
|
||||||
|
<div className="p-2 cursor-move text-muted-foreground">
|
||||||
|
<GripVertical className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full sm:w-48 h-32 sm:h-24 rounded-md overflow-hidden bg-slate-100 flex-shrink-0">
|
||||||
|
<Image
|
||||||
|
src={slider.image_url}
|
||||||
|
alt={slider.title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 grid gap-1 text-center sm:text-left">
|
||||||
|
<div className="flex items-center gap-2 justify-center sm:justify-start">
|
||||||
|
<h3 className="font-semibold truncate">{slider.title}</h3>
|
||||||
|
{!slider.is_active && (
|
||||||
|
<Badge variant="secondary">Pasif</Badge>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline">Sıra: {slider.order}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
|
{slider.description || "Açıklama yok"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 p-2">
|
||||||
|
<Link href={`/dashboard/sliders/${slider.id}`}>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{/* Delete functionality usually needs a client component or form action,
|
||||||
|
for simplicity here we will just link to edit,
|
||||||
|
or we can add a delete button with server action in a separate client component if needed.
|
||||||
|
Ideally, list items should be client components to handle delete easily.
|
||||||
|
*/}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
app/(dashboard)/dashboard/sms/logs/page.tsx
Normal file
65
app/(dashboard)/dashboard/sms/logs/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { getSmsLogs } from "@/lib/sms/actions"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
import { tr } from "date-fns/locale"
|
||||||
|
|
||||||
|
export default async function SmsLogsPage() {
|
||||||
|
const { data: logs, error } = await getSmsLogs(100)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="p-8">Hata: {error}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">SMS Geçmişi</h2>
|
||||||
|
<p className="text-muted-foreground">Son gönderilen mesajların durumu.</p>
|
||||||
|
|
||||||
|
<div className="rounded-md border overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[180px]">Tarih</TableHead>
|
||||||
|
<TableHead>Numara</TableHead>
|
||||||
|
<TableHead>Mesaj</TableHead>
|
||||||
|
<TableHead>Durum</TableHead>
|
||||||
|
<TableHead className="text-right">Kod</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{logs?.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="h-24 text-center">
|
||||||
|
Kayıt bulunamadı.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{logs?.map((log) => (
|
||||||
|
<TableRow key={log.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{log.created_at ? format(new Date(log.created_at), "dd MMM yyyy HH:mm", { locale: tr }) : "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{log.phone}</TableCell>
|
||||||
|
<TableCell className="max-w-[300px] truncate" title={log.message}>{log.message}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={log.status === 'success' ? 'default' : 'destructive'}>
|
||||||
|
{log.status === 'success' ? 'Başarılı' : 'Hatalı'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono text-xs">{log.response_code}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
app/(dashboard)/dashboard/sms/page.tsx
Normal file
9
app/(dashboard)/dashboard/sms/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { getCustomers } from "@/lib/customers/actions"
|
||||||
|
import SmsPageClient from "@/components/dashboard/sms-page-client"
|
||||||
|
|
||||||
|
export default async function SmsPage() {
|
||||||
|
// Fetch all customers to show in the list
|
||||||
|
const { data: customers } = await getCustomers()
|
||||||
|
|
||||||
|
return <SmsPageClient customers={customers || []} />
|
||||||
|
}
|
||||||
@@ -64,6 +64,7 @@ async function getUserDetails(userId: string) {
|
|||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
email: user.email || "",
|
email: user.email || "",
|
||||||
role: profile.role as "admin" | "user"
|
role: profile.role as "admin" | "user",
|
||||||
|
phone: profile.phone || ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { createClient } from "@/lib/supabase-server"
|
import { createClient } from "@/lib/supabase-server"
|
||||||
import { createClient as createSupabaseClient } from "@supabase/supabase-js"
|
import { createClient as createSupabaseClient } from "@supabase/supabase-js"
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
import { redirect } from "next/navigation"
|
|
||||||
|
|
||||||
// WARNING: specialized client for admin actions only
|
// WARNING: specialized client for admin actions only
|
||||||
// This requires SUPABASE_SERVICE_ROLE_KEY to be set in .env.local
|
// This requires SUPABASE_SERVICE_ROLE_KEY to be set in .env.local
|
||||||
@@ -18,21 +17,13 @@ const supabaseAdmin = createSupabaseClient(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export async function createUser(firstName: string, lastName: string, email: string, password: string, role: 'admin' | 'user') {
|
export async function createUser(firstName: string, lastName: string, email: string, password: string, role: 'admin' | 'user', phone?: string) {
|
||||||
const supabase = createClient()
|
|
||||||
|
|
||||||
// 1. Check if current user is admin
|
// 1. Check if current user is admin
|
||||||
const { data: { user: currentUser } } = await supabase.auth.getUser()
|
try {
|
||||||
if (!currentUser) return { error: "Oturum açmanız gerekiyor." }
|
await assertAdmin()
|
||||||
|
} catch (error) {
|
||||||
const { data: profile } = await supabase
|
return { error: (error as Error).message }
|
||||||
.from('profiles')
|
|
||||||
.select('role')
|
|
||||||
.eq('id', currentUser.id)
|
|
||||||
.single()
|
|
||||||
|
|
||||||
if (!profile || profile.role !== 'admin') {
|
|
||||||
return { error: "Yetkisiz işlem. Sadece yöneticiler kullanıcı oluşturabilir." }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Create user using Admin client
|
// 2. Create user using Admin client
|
||||||
@@ -59,7 +50,8 @@ export async function createUser(firstName: string, lastName: string, email: str
|
|||||||
.insert({
|
.insert({
|
||||||
id: newUser.user.id,
|
id: newUser.user.id,
|
||||||
full_name: `${firstName} ${lastName}`.trim(),
|
full_name: `${firstName} ${lastName}`.trim(),
|
||||||
role: role
|
role: role,
|
||||||
|
phone: phone
|
||||||
})
|
})
|
||||||
|
|
||||||
if (profileError) {
|
if (profileError) {
|
||||||
@@ -73,14 +65,13 @@ export async function createUser(firstName: string, lastName: string, email: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteUser(userId: string) {
|
export async function deleteUser(userId: string) {
|
||||||
const supabase = createClient()
|
|
||||||
|
|
||||||
// Check admin
|
// Check admin
|
||||||
const { data: { user: currentUser } } = await supabase.auth.getUser()
|
try {
|
||||||
if (!currentUser) return { error: "Oturum açmanız gerekiyor." }
|
await assertAdmin()
|
||||||
|
} catch (error) {
|
||||||
const { data: profile } = await supabase.from('profiles').select('role').eq('id', currentUser.id).single()
|
return { error: (error as Error).message }
|
||||||
if (profile?.role !== 'admin') return { error: "Yetkisiz işlem." }
|
}
|
||||||
|
|
||||||
// Delete user
|
// Delete user
|
||||||
const { error } = await supabaseAdmin.auth.admin.deleteUser(userId)
|
const { error } = await supabaseAdmin.auth.admin.deleteUser(userId)
|
||||||
@@ -91,30 +82,29 @@ export async function deleteUser(userId: string) {
|
|||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUser(userId: string, data: { firstName: string, lastName: string, email: string, password?: string, role: 'admin' | 'user' }) {
|
export async function updateUser(userId: string, data: { firstName: string, lastName: string, email: string, password?: string, role: 'admin' | 'user', phone?: string }) {
|
||||||
const supabase = createClient()
|
|
||||||
|
|
||||||
// Check admin
|
// Check admin
|
||||||
const { data: { user: currentUser } } = await supabase.auth.getUser()
|
try {
|
||||||
if (!currentUser) return { error: "Oturum açmanız gerekiyor." }
|
await assertAdmin()
|
||||||
|
} catch (error) {
|
||||||
// Check if current user is admin
|
return { error: (error as Error).message }
|
||||||
const { data: profile } = await supabase.from('profiles').select('role').eq('id', currentUser.id).single()
|
}
|
||||||
if (profile?.role !== 'admin') return { error: "Yetkisiz işlem." }
|
|
||||||
|
|
||||||
// 1. Update Profile (Role and Name)
|
// 1. Update Profile (Role and Name)
|
||||||
const { error: profileError } = await supabaseAdmin
|
const { error: profileError } = await supabaseAdmin
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.update({
|
.update({
|
||||||
full_name: `${data.firstName} ${data.lastName}`.trim(),
|
full_name: `${data.firstName} ${data.lastName}`.trim(),
|
||||||
role: data.role
|
role: data.role,
|
||||||
|
phone: data.phone
|
||||||
})
|
})
|
||||||
.eq('id', userId)
|
.eq('id', userId)
|
||||||
|
|
||||||
if (profileError) return { error: "Profil güncellenemedi: " + profileError.message }
|
if (profileError) return { error: "Profil güncellenemedi: " + profileError.message }
|
||||||
|
|
||||||
// 2. Update Auth (Email and Password)
|
// 2. Update Auth (Email and Password)
|
||||||
const authUpdates: any = {
|
const authUpdates: { email: string; user_metadata: { full_name: string }; password?: string } = {
|
||||||
email: data.email,
|
email: data.email,
|
||||||
user_metadata: {
|
user_metadata: {
|
||||||
full_name: `${data.firstName} ${data.lastName}`.trim()
|
full_name: `${data.firstName} ${data.lastName}`.trim()
|
||||||
@@ -131,3 +121,50 @@ export async function updateUser(userId: string, data: { firstName: string, last
|
|||||||
revalidatePath("/dashboard/users")
|
revalidatePath("/dashboard/users")
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateProfile(data: { firstName: string, lastName: string, phone?: string }) {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
if (!user) return { error: "Oturum açmanız gerekiyor." }
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.update({
|
||||||
|
full_name: `${data.firstName} ${data.lastName}`.trim(),
|
||||||
|
phone: data.phone
|
||||||
|
})
|
||||||
|
.eq('id', user.id)
|
||||||
|
|
||||||
|
if (error) return { error: "Profil güncellenemedi: " + error.message }
|
||||||
|
|
||||||
|
// Update Auth Metadata as well
|
||||||
|
if (data.firstName || data.lastName) {
|
||||||
|
await supabase.auth.updateUser({
|
||||||
|
data: {
|
||||||
|
full_name: `${data.firstName} ${data.lastName}`.trim()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/profile")
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertAdmin() {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data: { user: currentUser } } = await supabase.auth.getUser()
|
||||||
|
if (!currentUser) throw new Error("Oturum açmanız gerekiyor.")
|
||||||
|
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('role')
|
||||||
|
.eq('id', currentUser.id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (!profile || profile.role !== 'admin') {
|
||||||
|
throw new Error("Yetkisiz işlem. Sadece yöneticiler bu işlemi gerçekleştirebilir.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentUser
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import { getProfile } from "@/lib/data"
|
||||||
import { createClient } from "@/lib/supabase-server"
|
import { createClient } from "@/lib/supabase-server"
|
||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
import { Sidebar } from "@/components/dashboard/sidebar"
|
import { Sidebar } from "@/components/dashboard/sidebar"
|
||||||
import { DashboardHeader } from "@/components/dashboard/header"
|
import { DashboardHeader } from "@/components/dashboard/header"
|
||||||
|
|
||||||
|
import { AutoLogoutHandler } from "@/components/dashboard/auto-logout-handler"
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
@@ -15,8 +18,11 @@ export default async function DashboardLayout({
|
|||||||
redirect("/login")
|
redirect("/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const profile = await getProfile(user.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen w-full flex-col bg-muted/40">
|
<div className="flex min-h-screen w-full flex-col bg-muted/40">
|
||||||
|
<AutoLogoutHandler />
|
||||||
<aside className="fixed inset-y-0 left-0 z-10 hidden w-64 flex-col border-r bg-background sm:flex">
|
<aside className="fixed inset-y-0 left-0 z-10 hidden w-64 flex-col border-r bg-background sm:flex">
|
||||||
<div className="flex h-14 items-center border-b px-4 lg:h-[60px] lg:px-6">
|
<div className="flex h-14 items-center border-b px-4 lg:h-[60px] lg:px-6">
|
||||||
<span className="font-semibold text-lg">ParaKasa Panel</span>
|
<span className="font-semibold text-lg">ParaKasa Panel</span>
|
||||||
@@ -24,7 +30,7 @@ export default async function DashboardLayout({
|
|||||||
<Sidebar className="flex-1" />
|
<Sidebar className="flex-1" />
|
||||||
</aside>
|
</aside>
|
||||||
<div className="flex flex-col sm:gap-4 sm:py-4 sm:pl-64">
|
<div className="flex flex-col sm:gap-4 sm:py-4 sm:pl-64">
|
||||||
<DashboardHeader />
|
<DashboardHeader user={user} profile={profile} />
|
||||||
<main className="grid flex-1 items-start gap-4 p-4 sm:px-6 sm:py-0 md:gap-8">
|
<main className="grid flex-1 items-start gap-4 p-4 sm:px-6 sm:py-0 md:gap-8">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
27
app/(dashboard)/loading.tsx
Normal file
27
app/(dashboard)/loading.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function DashboardLoading() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col space-y-6 p-8">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-[200px]" />
|
||||||
|
<Skeleton className="h-4 w-[300px]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Skeleton className="h-10 w-[120px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Skeleton className="h-[120px] rounded-xl" />
|
||||||
|
<Skeleton className="h-[120px] rounded-xl" />
|
||||||
|
<Skeleton className="h-[120px] rounded-xl" />
|
||||||
|
<Skeleton className="h-[120px] rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||||
|
<Skeleton className="col-span-4 h-[400px] rounded-xl" />
|
||||||
|
<Skeleton className="col-span-3 h-[400px] rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,61 +1,22 @@
|
|||||||
"use client"
|
import { getSiteContents } from "@/lib/data"
|
||||||
|
import { ContactForm } from "@/components/contact/contact-form"
|
||||||
|
import { Mail, MapPin, Phone, Instagram, Youtube } from "lucide-react"
|
||||||
|
import { FaTiktok } from "react-icons/fa"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
import { useState } from "react"
|
export default async function ContactPage() {
|
||||||
import { useForm } from "react-hook-form"
|
const siteSettings = await getSiteContents()
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
|
||||||
import { Mail, MapPin, Phone, Loader2, CheckCircle } from "lucide-react"
|
|
||||||
import { contactFormSchema, ContactFormValues } from "@/lib/schemas"
|
|
||||||
import { submitContactForm } from "@/lib/actions/contact"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
export default function ContactPage() {
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
||||||
const [isSuccess, setIsSuccess] = useState(false)
|
|
||||||
|
|
||||||
const form = useForm<ContactFormValues>({
|
|
||||||
resolver: zodResolver(contactFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: "",
|
|
||||||
surname: "",
|
|
||||||
email: "",
|
|
||||||
phone: "",
|
|
||||||
subject: "",
|
|
||||||
message: "",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
async function onSubmit(data: ContactFormValues) {
|
|
||||||
setIsSubmitting(true)
|
|
||||||
try {
|
|
||||||
const response = await submitContactForm(data)
|
|
||||||
if (response.success) {
|
|
||||||
setIsSuccess(true)
|
|
||||||
form.reset()
|
|
||||||
toast.success("Mesajınız başarıyla gönderildi.")
|
|
||||||
} else {
|
|
||||||
toast.error("Hata: " + response.error)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Bir hata oluştu.")
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container py-12 md:py-24">
|
<div className="container py-12 md:py-24">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-8 md:mb-12">
|
||||||
<h1 className="text-4xl font-bold tracking-tight mb-4 font-outfit">İletişime Geçin</h1>
|
<h1 className="text-3xl md:text-4xl font-bold tracking-tight mb-4 font-outfit">İletişime Geçin</h1>
|
||||||
<p className="text-muted-foreground max-w-xl mx-auto">
|
<p className="text-muted-foreground max-w-xl mx-auto">
|
||||||
Sorularınız, teklif talepleriniz veya teknik destek için bize ulaşın.
|
Sorularınız, teklif talepleriniz veya teknik destek için bize ulaşın.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-12 max-w-5xl mx-auto">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 max-w-5xl mx-auto">
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h2 className="text-2xl font-semibold">İletişim Bilgileri</h2>
|
<h2 className="text-2xl font-semibold">İletişim Bilgileri</h2>
|
||||||
@@ -63,9 +24,8 @@ export default function ContactPage() {
|
|||||||
<MapPin className="w-6 h-6 text-primary mt-1" />
|
<MapPin className="w-6 h-6 text-primary mt-1" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">Merkez Ofis & Showroom</p>
|
<p className="font-medium">Merkez Ofis & Showroom</p>
|
||||||
<p className="text-slate-600 dark:text-slate-400">
|
<p className="text-slate-600 dark:text-slate-400 whitespace-pre-wrap">
|
||||||
Organize Sanayi Bölgesi, 12. Cadde No: 45<br />
|
{siteSettings.contact_address || "Organize Sanayi Bölgesi, 12. Cadde No: 45\nBaşakşehir, İstanbul"}
|
||||||
Başakşehir, İstanbul
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,110 +33,60 @@ export default function ContactPage() {
|
|||||||
<Phone className="w-6 h-6 text-primary" />
|
<Phone className="w-6 h-6 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">Telefon</p>
|
<p className="font-medium">Telefon</p>
|
||||||
<p className="text-slate-600 dark:text-slate-400">+90 (212) 555 00 00</p>
|
<p className="text-slate-600 dark:text-slate-400">
|
||||||
|
<a
|
||||||
|
href={`tel:${(siteSettings.contact_phone || "+90 (212) 555 00 00").replace(/[^\d+]/g, '')}`}
|
||||||
|
className="hover:text-primary transition-colors hover:underline"
|
||||||
|
>
|
||||||
|
{siteSettings.contact_phone || "+90 (212) 555 00 00"}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<Mail className="w-6 h-6 text-primary" />
|
<Mail className="w-6 h-6 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">E-posta</p>
|
<p className="font-medium">E-posta</p>
|
||||||
<p className="text-slate-600 dark:text-slate-400">info@parakasa.com</p>
|
<p className="text-slate-600 dark:text-slate-400">
|
||||||
</div>
|
{siteSettings.contact_email || "info@parakasa.com"}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="aspect-video bg-slate-100 rounded-lg overflow-hidden relative">
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
|
|
||||||
Harita (Google Maps Embed)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6 sm:p-8">
|
|
||||||
{isSuccess ? (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full min-h-[400px] text-center space-y-4">
|
|
||||||
<CheckCircle className="w-16 h-16 text-green-500" />
|
|
||||||
<h3 className="text-2xl font-bold">Mesajınız Alındı!</h3>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
En kısa sürede size dönüş yapacağız.
|
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => setIsSuccess(false)} variant="outline">
|
|
||||||
Yeni Mesaj Gönder
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="name" className="text-sm font-medium">Adınız</label>
|
|
||||||
<Input id="name" {...form.register("name")} placeholder="Adınız" />
|
|
||||||
{form.formState.errors.name && <p className="text-xs text-red-500">{form.formState.errors.name.message}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 w-[210px]">
|
|
||||||
<label htmlFor="surname" className="text-sm font-medium">Soyadınız</label>
|
|
||||||
<Input id="surname" {...form.register("surname")} placeholder="Soyadınız" />
|
|
||||||
{form.formState.errors.surname && <p className="text-xs text-red-500">{form.formState.errors.surname.message}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="email" className="text-sm font-medium">E-posta</label>
|
|
||||||
<Input id="email" type="email" {...form.register("email")} placeholder="ornek@sirket.com" />
|
|
||||||
{form.formState.errors.email && <p className="text-xs text-red-500">{form.formState.errors.email.message}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="phone" className="text-sm font-medium">Telefon</label>
|
|
||||||
<div className="relative w-[210px]">
|
|
||||||
<div className="absolute left-3 top-2 text-muted-foreground text-sm flex items-center gap-2 font-medium z-10 select-none pointer-events-none">
|
|
||||||
<span>🇹🇷</span>
|
|
||||||
<span>+90</span>
|
|
||||||
<div className="w-px h-4 bg-border" />
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
id="phone"
|
|
||||||
type="tel"
|
|
||||||
className="pl-20 font-mono"
|
|
||||||
placeholder="(5XX) XXX XX XX"
|
|
||||||
maxLength={15}
|
|
||||||
{...form.register("phone", {
|
|
||||||
onChange: (e) => {
|
|
||||||
let value = e.target.value.replace(/\D/g, ''); // Remove non-digits
|
|
||||||
if (value.startsWith('90')) value = value.slice(2); // Remove leading 90 if user types it
|
|
||||||
|
|
||||||
// Format: (5XX) XXX XX XX
|
<div className="pt-4 border-t">
|
||||||
let formattedValue = '';
|
<h3 className="text-lg font-semibold mb-3">Sosyal Medya</h3>
|
||||||
if (value.length > 0) formattedValue += '(' + value.substring(0, 3);
|
<div className="flex gap-4">
|
||||||
if (value.length > 3) formattedValue += ') ' + value.substring(3, 6);
|
{siteSettings.social_instagram && (
|
||||||
if (value.length > 6) formattedValue += ' ' + value.substring(6, 8);
|
<Link href={siteSettings.social_instagram} target="_blank" className="p-2 bg-slate-100 dark:bg-slate-800 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors text-slate-700 dark:text-slate-300">
|
||||||
if (value.length > 8) formattedValue += ' ' + value.substring(8, 10);
|
<Instagram className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
e.target.value = formattedValue;
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{form.formState.errors.phone && <p className="text-xs text-red-500">{form.formState.errors.phone.message}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="subject" className="text-sm font-medium">Konu</label>
|
|
||||||
<Input id="subject" {...form.register("subject")} placeholder="Konu" />
|
|
||||||
{form.formState.errors.subject && <p className="text-xs text-red-500">{form.formState.errors.subject.message}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="message" className="text-sm font-medium">Mesajınız</label>
|
|
||||||
<Textarea id="message" {...form.register("message")} placeholder="Size nasıl yardımcı olabiliriz?" className="min-h-[120px]" />
|
|
||||||
{form.formState.errors.message && <p className="text-xs text-red-500">{form.formState.errors.message.message}</p>}
|
|
||||||
</div>
|
|
||||||
<Button size="lg" className="w-full" disabled={isSubmitting}>
|
|
||||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
Mesaj Gönder
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
{siteSettings.social_youtube && (
|
||||||
</Card>
|
<Link href={siteSettings.social_youtube} target="_blank" className="p-2 bg-slate-100 dark:bg-slate-800 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors text-slate-700 dark:text-slate-300">
|
||||||
|
<Youtube className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{siteSettings.social_tiktok && (
|
||||||
|
<Link href={siteSettings.social_tiktok} target="_blank" className="p-2 bg-slate-100 dark:bg-slate-800 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors text-slate-700 dark:text-slate-300">
|
||||||
|
<FaTiktok className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{siteSettings.contact_map_embed ? (
|
||||||
|
<div
|
||||||
|
className="aspect-video bg-slate-100 rounded-lg overflow-hidden relative"
|
||||||
|
dangerouslySetInnerHTML={{ __html: siteSettings.contact_map_embed }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="aspect-video bg-slate-100 rounded-lg overflow-hidden relative flex items-center justify-center text-muted-foreground">
|
||||||
|
Harita henüz eklenmemiş.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ContactForm />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
33
app/(public)/loading.tsx
Normal file
33
app/(public)/loading.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function PublicLoading() {
|
||||||
|
return (
|
||||||
|
<div className="container py-12 md:py-24">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="aspect-square w-full rounded-xl" />
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<Skeleton className="aspect-square w-full rounded-xl" />
|
||||||
|
<Skeleton className="aspect-square w-full rounded-xl" />
|
||||||
|
<Skeleton className="aspect-square w-full rounded-xl" />
|
||||||
|
<Skeleton className="aspect-square w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-8 w-1/3" />
|
||||||
|
<Skeleton className="h-12 w-2/3" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 pt-6">
|
||||||
|
<Skeleton className="h-12 w-48" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,9 +34,10 @@ export default function LoginPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push("/dashboard")
|
// Redirect to 2FA verification instead of dashboard
|
||||||
|
router.push("/verify-2fa")
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} catch (err: any) {
|
} catch {
|
||||||
setError("Bir hata oluştu. Lütfen tekrar deneyin.")
|
setError("Bir hata oluştu. Lütfen tekrar deneyin.")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { ArrowRight, ShieldCheck, Lock, Award, History, LayoutDashboard, LogIn } from "lucide-react"
|
import { ArrowRight, ShieldCheck, Lock, History, LayoutDashboard } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { createClient } from "@/lib/supabase-server"
|
import { createClient } from "@/lib/supabase-server"
|
||||||
|
import { getSiteContents } from "@/lib/data"
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
const contents = await getSiteContents()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex flex-col min-h-screen">
|
||||||
@@ -15,7 +17,7 @@ export default async function Home() {
|
|||||||
<section className="relative w-full h-[80vh] flex items-center bg-black overflow-hidden">
|
<section className="relative w-full h-[80vh] flex items-center bg-black overflow-hidden">
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
<Image
|
<Image
|
||||||
src="/images/hero-safe.png"
|
src={contents.home_hero_bg_image || "/images/hero-safe.png"}
|
||||||
alt="Premium Çelik Kasa"
|
alt="Premium Çelik Kasa"
|
||||||
fill
|
fill
|
||||||
className="object-cover opacity-60"
|
className="object-cover opacity-60"
|
||||||
@@ -26,16 +28,16 @@ export default async function Home() {
|
|||||||
|
|
||||||
<div className="container relative z-10 px-4 md:px-6">
|
<div className="container relative z-10 px-4 md:px-6">
|
||||||
<div className="max-w-2xl space-y-4">
|
<div className="max-w-2xl space-y-4">
|
||||||
<h1 className="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tighter text-white font-outfit">
|
<h1
|
||||||
GÜVENLİK SINIR <br /> <span className="text-transparent bg-clip-text bg-gradient-to-r from-slate-200 to-slate-500">TANIMAZ</span>
|
className="text-3xl md:text-6xl lg:text-7xl font-bold tracking-tighter text-white font-outfit"
|
||||||
</h1>
|
dangerouslySetInnerHTML={{ __html: contents.home_hero_title || 'GÜVENLİK SINIR <br /> <span class="text-transparent bg-clip-text bg-gradient-to-r from-slate-200 to-slate-500">TANIMAZ</span>' }}
|
||||||
|
/>
|
||||||
<p className="text-lg md:text-xl text-slate-300 max-w-[600px]">
|
<p className="text-lg md:text-xl text-slate-300 max-w-[600px]">
|
||||||
En değerli varlıklarınız için tasarlanmış yüksek güvenlikli çelik kasalar.
|
{contents.home_hero_description || "En değerli varlıklarınız için tasarlanmış yüksek güvenlikli çelik kasalar. Modern teknoloji ve zanaatkarlığın mükemmel uyumu."}
|
||||||
Modern teknoloji ve zanaatkarlığın mükemmel uyumu.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-4 mt-8">
|
<div className="flex flex-col sm:flex-row gap-4 mt-8">
|
||||||
<Button size="lg" className="bg-slate-100 text-slate-900 hover:bg-slate-200 font-semibold text-lg px-8">
|
<Button size="lg" className="bg-slate-100 text-slate-900 hover:bg-slate-200 font-semibold text-lg px-8" asChild>
|
||||||
<Link href="/products">Koleksiyonu İncele</Link>
|
<Link href="/products">{contents.home_hero_button_text || "Koleksiyonu İncele"}</Link>
|
||||||
</Button>
|
</Button>
|
||||||
{user && (
|
{user && (
|
||||||
<Button size="lg" variant="outline" className="border-slate-400 text-slate-100 hover:bg-slate-800 hover:text-white font-semibold text-lg px-8">
|
<Button size="lg" variant="outline" className="border-slate-400 text-slate-100 hover:bg-slate-800 hover:text-white font-semibold text-lg px-8">
|
||||||
@@ -54,9 +56,9 @@ export default async function Home() {
|
|||||||
<section className="py-20 md:py-32 bg-slate-50 dark:bg-slate-950">
|
<section className="py-20 md:py-32 bg-slate-50 dark:bg-slate-950">
|
||||||
<div className="container px-4 md:px-6">
|
<div className="container px-4 md:px-6">
|
||||||
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
|
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
|
||||||
<h2 className="text-3xl md:text-5xl font-bold tracking-tighter font-outfit">Ürün Kategorileri</h2>
|
<h2 className="text-3xl md:text-5xl font-bold tracking-tighter font-outfit">{contents.home_categories_title || "Ürün Kategorileri"}</h2>
|
||||||
<p className="max-w-[700px] text-muted-foreground md:text-lg">
|
<p className="max-w-[700px] text-muted-foreground md:text-lg">
|
||||||
İhtiyacınıza uygun güvenlik çözümünü seçin.
|
{contents.home_categories_description || "İhtiyacınıza uygun güvenlik çözümünü seçin."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
104
app/(public)/products/[id]/page.tsx
Normal file
104
app/(public)/products/[id]/page.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { createClient } from "@/lib/supabase-server"
|
||||||
|
import { getSiteContents } from "@/lib/data"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Phone } from "lucide-react"
|
||||||
|
import { ProductGallery } from "@/components/product/product-gallery"
|
||||||
|
|
||||||
|
async function getProduct(id: string) {
|
||||||
|
const supabase = createClient()
|
||||||
|
|
||||||
|
// Fetch product
|
||||||
|
const { data: product, error } = await supabase
|
||||||
|
.from("products")
|
||||||
|
.select("*")
|
||||||
|
.eq("id", id)
|
||||||
|
.eq("is_active", true)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error || !product) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch images
|
||||||
|
const { data: images } = await supabase
|
||||||
|
.from("product_images")
|
||||||
|
.select("*")
|
||||||
|
.eq("product_id", id)
|
||||||
|
.order("display_order", { ascending: true })
|
||||||
|
|
||||||
|
return { ...product, images: images || [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProductPage({ params }: { params: { id: string } }) {
|
||||||
|
const product = await getProduct(params.id)
|
||||||
|
const siteSettings = await getSiteContents()
|
||||||
|
const whatsappPhone = siteSettings.contact_phone ? siteSettings.contact_phone.replace(/\s+/g, '') : "905555555555"
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine main image and gallery images for a full list, filtering duplicates if necessary
|
||||||
|
// Logic: If gallery images exist, use them. If not, fallback to product.image_url.
|
||||||
|
// If product.image_url is in product_images, we might duplicate, but let's just use all distinct.
|
||||||
|
|
||||||
|
let allImages: string[] = []
|
||||||
|
if (product.images && product.images.length > 0) {
|
||||||
|
allImages = product.images.map((img: { image_url: string }) => img.image_url)
|
||||||
|
} else if (product.image_url) {
|
||||||
|
allImages = [product.image_url]
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container py-12 md:py-24">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||||
|
{/* Image Gallery Section */}
|
||||||
|
<ProductGallery images={allImages} productName={product.name} />
|
||||||
|
|
||||||
|
{/* Product Info Section */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-4 mb-2">
|
||||||
|
<Badge variant="secondary" className="text-sm uppercase tracking-wider">
|
||||||
|
{product.category}
|
||||||
|
</Badge>
|
||||||
|
{product.product_code && (
|
||||||
|
<span className="text-sm font-semibold text-slate-500">
|
||||||
|
Kod: {product.product_code}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight text-primary font-outfit mb-4">
|
||||||
|
{product.name}
|
||||||
|
</h1>
|
||||||
|
{/* NO PRICE DISPLAY as requested */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="prose prose-slate dark:prose-invert max-w-none">
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Ürün 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,48 +1,31 @@
|
|||||||
|
|
||||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
import { createClient } from "@/lib/supabase-server"
|
||||||
|
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import Link from "next/link"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
|
|
||||||
const products = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: "Ev Tipi Çelik Kasa",
|
|
||||||
image: "/images/safe-1.jpg",
|
|
||||||
category: "Ev",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: "Ofis Tipi Yanmaz Kasa",
|
|
||||||
image: "/images/safe-2.jpg",
|
|
||||||
category: "Ofis",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: "Otel Odası Kasası",
|
|
||||||
image: "/images/safe-3.jpg",
|
|
||||||
category: "Otel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: "Silah Kasası (Tüfek)",
|
|
||||||
image: "/images/safe-4.jpg",
|
|
||||||
category: "Özel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
name: "Kuyumcu Kasası",
|
|
||||||
image: "/images/safe-5.jpg",
|
|
||||||
category: "Ticari",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
name: "Duvar İçi Gizli Kasa",
|
|
||||||
image: "/images/safe-6.jpg",
|
|
||||||
category: "Ev",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function ProductsPage() {
|
|
||||||
|
// Helper to get products
|
||||||
|
async function getProducts() {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("products")
|
||||||
|
.select("*")
|
||||||
|
.eq("is_active", true)
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Error fetching products:", error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProductsPage() {
|
||||||
|
const products = await getProducts()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container py-12 md:py-24">
|
<div className="container py-12 md:py-24">
|
||||||
<div className="flex flex-col items-center mb-12 text-center">
|
<div className="flex flex-col items-center mb-12 text-center">
|
||||||
@@ -53,27 +36,50 @@ export default function ProductsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{products.map((product) => (
|
{products && products.length > 0 ? (
|
||||||
<Card key={product.id} className="overflow-hidden border-0 shadow-md hover:shadow-xl transition-all duration-300">
|
products.map((product) => (
|
||||||
|
<Card key={product.id} className="overflow-hidden border-0 shadow-md hover:shadow-xl transition-all duration-300 group">
|
||||||
<div className="aspect-[4/5] relative bg-slate-100 dark:bg-slate-800">
|
<div className="aspect-[4/5] relative bg-slate-100 dark:bg-slate-800">
|
||||||
{/* Placeholder for real images */}
|
{product.image_url ? (
|
||||||
|
<Image
|
||||||
|
src={product.image_url}
|
||||||
|
alt={product.name}
|
||||||
|
fill
|
||||||
|
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-slate-400">
|
<div className="absolute inset-0 flex items-center justify-center text-slate-400">
|
||||||
<span className="text-sm">Görsel: {product.name}</span>
|
<span className="text-sm">Görsel Yok</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div className="w-full">
|
||||||
|
<div className="flex justify-between items-center w-full">
|
||||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wider">{product.category}</span>
|
<span className="text-xs font-medium text-slate-500 uppercase tracking-wider">{product.category}</span>
|
||||||
|
{product.product_code && (
|
||||||
|
<span className="text-xs font-semibold text-slate-400">#{product.product_code}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<CardTitle className="text-lg mt-1">{product.name}</CardTitle>
|
<CardTitle className="text-lg mt-1">{product.name}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter className="p-4 pt-0">
|
<CardFooter className="p-4 pt-0">
|
||||||
<Button className="w-full">Detayları İncele</Button>
|
<Button className="w-full" variant="outline" asChild>
|
||||||
|
<Link href={`/products/${product.id}`}>
|
||||||
|
Detayları İncele
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<div className="col-span-full text-center py-12">
|
||||||
|
<p className="text-muted-foreground">Henüz vitrinde ürünümüz bulunmuyor.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { supabase } from "@/lib/supabase"
|
import { supabase } from "@/lib/supabase"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -11,7 +11,6 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
|||||||
import { AlertCircle, Loader2, CheckCircle2 } from "lucide-react"
|
import { AlertCircle, Loader2, CheckCircle2 } from "lucide-react"
|
||||||
|
|
||||||
export default function SignUpPage() {
|
export default function SignUpPage() {
|
||||||
const router = useRouter()
|
|
||||||
const [email, setEmail] = useState("")
|
const [email, setEmail] = useState("")
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -40,7 +39,7 @@ export default function SignUpPage() {
|
|||||||
if (data.user) {
|
if (data.user) {
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch {
|
||||||
setError("Bir hata oluştu. Lütfen tekrar deneyin.")
|
setError("Bir hata oluştu. Lütfen tekrar deneyin.")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -76,7 +75,7 @@ export default function SignUpPage() {
|
|||||||
<CardHeader className="space-y-1">
|
<CardHeader className="space-y-1">
|
||||||
<CardTitle className="text-2xl font-bold">Hesap Oluştur</CardTitle>
|
<CardTitle className="text-2xl font-bold">Hesap Oluştur</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
ParaKasa'ya katılmak için bilgilerinizi girin
|
ParaKasa'ya katılmak için bilgilerinizi girin
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
130
app/(public)/verify-2fa/page.tsx
Normal file
130
app/(public)/verify-2fa/page.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { AlertCircle, Loader2 } from "lucide-react"
|
||||||
|
import { sendVerificationCode, verifyCode } from "@/lib/sms/verification-actions"
|
||||||
|
import { createClient } from "@/lib/supabase-browser"
|
||||||
|
|
||||||
|
export default function Verify2FAPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [code, setCode] = useState("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [sent, setSent] = useState(false)
|
||||||
|
const [maskedPhone, setMaskedPhone] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Init: Send code automatically
|
||||||
|
const init = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
const result = await sendVerificationCode()
|
||||||
|
setLoading(false)
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error)
|
||||||
|
} else {
|
||||||
|
setSent(true)
|
||||||
|
setMaskedPhone(result.phone || "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
init()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleVerify = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await verifyCode(code)
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error)
|
||||||
|
} else {
|
||||||
|
router.push("/dashboard")
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Bir hata oluştu.")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResend = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const result = await sendVerificationCode()
|
||||||
|
setLoading(false)
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error)
|
||||||
|
} else {
|
||||||
|
setSent(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-slate-50 dark:bg-slate-950 px-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-2xl font-bold">SMS Doğrulama</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{sent ? `Telefonunuza (***${maskedPhone}) gönderilen 6 haneli kodu girin.` : "Doğrulama kodu gönderiliyor..."}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 flex items-center space-x-2 text-sm text-red-600 bg-red-50 p-3 rounded-md">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleVerify} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="code">Doğrulama Kodu</Label>
|
||||||
|
<Input
|
||||||
|
id="code"
|
||||||
|
type="text"
|
||||||
|
placeholder="123456"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
maxLength={6}
|
||||||
|
className="text-center text-lg tracking-widest"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button className="w-full" type="submit" disabled={loading || !sent}>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Doğrula
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col gap-4 text-center text-sm text-muted-foreground">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResend}
|
||||||
|
disabled={loading}
|
||||||
|
className="underline underline-offset-4 hover:text-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Kodu Tekrar Gönder
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
const supabase = createClient()
|
||||||
|
await supabase.auth.signOut()
|
||||||
|
router.push("/login")
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
Giriş ekranına dön
|
||||||
|
</button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
@import 'react-phone-number-input/style.css';
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|||||||
@@ -1,26 +1,50 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import { Inter, Outfit } from "next/font/google";
|
import localFont from "next/font/local";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
import { getSiteContents } from "@/lib/data";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = localFont({
|
||||||
const outfit = Outfit({ subsets: ["latin"], variable: "--font-outfit" });
|
src: [
|
||||||
|
{ path: "../public/fonts/Inter-Thin.ttf", weight: "100", style: "normal" },
|
||||||
|
{ path: "../public/fonts/Inter-ExtraLight.ttf", weight: "200", style: "normal" },
|
||||||
|
{ path: "../public/fonts/Inter-Light.ttf", weight: "300", style: "normal" },
|
||||||
|
{ path: "../public/fonts/Inter-Regular.ttf", weight: "400", style: "normal" },
|
||||||
|
{ path: "../public/fonts/Inter-Medium.ttf", weight: "500", style: "normal" },
|
||||||
|
{ path: "../public/fonts/Inter-SemiBold.ttf", weight: "600", style: "normal" },
|
||||||
|
{ path: "../public/fonts/Inter-Bold.ttf", weight: "700", style: "normal" },
|
||||||
|
{ path: "../public/fonts/Inter-ExtraBold.ttf", weight: "800", style: "normal" },
|
||||||
|
{ path: "../public/fonts/Inter-Black.ttf", weight: "900", style: "normal" },
|
||||||
|
],
|
||||||
|
variable: "--font-inter",
|
||||||
|
});
|
||||||
|
|
||||||
|
const outfit = localFont({
|
||||||
|
src: [
|
||||||
|
{ path: "../public/fonts/Outfit-Thin.ttf", weight: "100", style: "normal" },
|
||||||
|
{ path: "../public/fonts/Outfit-ExtraLight.ttf", weight: "200", style: "normal" },
|
||||||
|
{ path: "../public/fonts/Outfit-Light.ttf", weight: "300", style: "normal" },
|
||||||
|
{ path: "../public/fonts/Outfit-Regular.ttf", weight: "400", style: "normal" },
|
||||||
|
{ path: "../public/fonts/Outfit-Medium.ttf", weight: "500", style: "normal" },
|
||||||
|
{ path: "../public/fonts/Outfit-SemiBold.ttf", weight: "600", style: "normal" },
|
||||||
|
{ path: "../public/fonts/Outfit-Bold.ttf", weight: "700", style: "normal" },
|
||||||
|
{ path: "../public/fonts/Outfit-ExtraBold.ttf", weight: "800", style: "normal" },
|
||||||
|
{ path: "../public/fonts/Outfit-Black.ttf", weight: "900", style: "normal" },
|
||||||
|
],
|
||||||
|
variable: "--font-outfit",
|
||||||
|
});
|
||||||
|
|
||||||
import { getSiteSettings } from "@/lib/site-settings";
|
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata() {
|
||||||
const settings = await getSiteSettings();
|
const settings = await getSiteContents();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: settings?.site_title || "ParaKasa - Premium Çelik Kasalar",
|
title: settings.site_title || "ParaKasa - Premium Çelik Kasalar",
|
||||||
description: settings?.site_description || "Eviniz ve iş yeriniz için en yüksek güvenlikli çelik kasa ve para sayma çözümleri.",
|
description: settings.site_description || "Eviniz ve iş yeriniz için en yüksek güvenlikli çelik kasa ve para sayma çözümleri.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
import { ThemeProvider } from "@/components/theme-provider"
|
|
||||||
|
|
||||||
// ... imports
|
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
|
|||||||
BIN
build_log.txt
Normal file
BIN
build_log.txt
Normal file
Binary file not shown.
BIN
build_log_2.txt
Normal file
BIN
build_log_2.txt
Normal file
Binary file not shown.
137
components/contact/contact-form.tsx
Normal file
137
components/contact/contact-form.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Loader2, CheckCircle } from "lucide-react"
|
||||||
|
import { contactFormSchema, ContactFormValues } from "@/lib/schemas"
|
||||||
|
import { submitContactForm } from "@/lib/actions/contact"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
export function ContactForm() {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [isSuccess, setIsSuccess] = useState(false)
|
||||||
|
|
||||||
|
const form = useForm<ContactFormValues>({
|
||||||
|
resolver: zodResolver(contactFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
surname: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
subject: "",
|
||||||
|
message: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onSubmit(data: ContactFormValues) {
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
const response = await submitContactForm(data)
|
||||||
|
if (response.success) {
|
||||||
|
setIsSuccess(true)
|
||||||
|
form.reset()
|
||||||
|
toast.success("Mesajınız başarıyla gönderildi.")
|
||||||
|
} else {
|
||||||
|
toast.error("Hata: " + response.error)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Bir hata oluştu.")
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 sm:p-8">
|
||||||
|
{isSuccess ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full min-h-[400px] text-center space-y-4">
|
||||||
|
<CheckCircle className="w-16 h-16 text-green-500" />
|
||||||
|
<h3 className="text-2xl font-bold">Mesajınız Alındı!</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
En kısa sürede size dönüş yapacağız.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => setIsSuccess(false)} variant="outline">
|
||||||
|
Yeni Mesaj Gönder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="name" className="text-sm font-medium">Adınız</label>
|
||||||
|
<Input id="name" {...form.register("name")} placeholder="Adınız" />
|
||||||
|
{form.formState.errors.name && <p className="text-xs text-red-500">{form.formState.errors.name.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 w-full">
|
||||||
|
<label htmlFor="surname" className="text-sm font-medium">Soyadınız</label>
|
||||||
|
<Input id="surname" {...form.register("surname")} placeholder="Soyadınız" />
|
||||||
|
{form.formState.errors.surname && <p className="text-xs text-red-500">{form.formState.errors.surname.message}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="email" className="text-sm font-medium">E-posta</label>
|
||||||
|
<Input id="email" type="email" {...form.register("email")} placeholder="ornek@sirket.com" />
|
||||||
|
{form.formState.errors.email && <p className="text-xs text-red-500">{form.formState.errors.email.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="phone" className="text-sm font-medium">Telefon</label>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<div className="absolute left-3 top-2 text-muted-foreground text-sm flex items-center gap-2 font-medium z-10 select-none pointer-events-none">
|
||||||
|
<span>🇹🇷</span>
|
||||||
|
<span>+90</span>
|
||||||
|
<div className="w-px h-4 bg-border" />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
type="tel"
|
||||||
|
className="pl-20 font-mono"
|
||||||
|
placeholder="(5XX) XXX XX XX"
|
||||||
|
maxLength={15}
|
||||||
|
{...form.register("phone", {
|
||||||
|
onChange: (e) => {
|
||||||
|
let value = e.target.value.replace(/\D/g, ''); // Remove non-digits
|
||||||
|
if (value.startsWith('90')) value = value.slice(2); // Remove leading 90 if user types it
|
||||||
|
|
||||||
|
// Format: (5XX) XXX XX XX
|
||||||
|
let formattedValue = '';
|
||||||
|
if (value.length > 0) formattedValue += '(' + value.substring(0, 3);
|
||||||
|
if (value.length > 3) formattedValue += ') ' + value.substring(3, 6);
|
||||||
|
if (value.length > 6) formattedValue += ' ' + value.substring(6, 8);
|
||||||
|
if (value.length > 8) formattedValue += ' ' + value.substring(8, 10);
|
||||||
|
|
||||||
|
e.target.value = formattedValue;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{form.formState.errors.phone && <p className="text-xs text-red-500">{form.formState.errors.phone.message}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="subject" className="text-sm font-medium">Konu</label>
|
||||||
|
<Input id="subject" {...form.register("subject")} placeholder="Konu" />
|
||||||
|
{form.formState.errors.subject && <p className="text-xs text-red-500">{form.formState.errors.subject.message}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="message" className="text-sm font-medium">Mesajınız</label>
|
||||||
|
<Textarea id="message" {...form.register("message")} placeholder="Size nasıl yardımcı olabiliriz?" className="min-h-[120px]" />
|
||||||
|
{form.formState.errors.message && <p className="text-xs text-red-500">{form.formState.errors.message.message}</p>}
|
||||||
|
</div>
|
||||||
|
<Button size="lg" className="w-full" disabled={isSubmitting}>
|
||||||
|
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Mesaj Gönder
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
components/dashboard/auto-logout-handler.tsx
Normal file
60
components/dashboard/auto-logout-handler.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useCallback, useRef } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { createClient } from "@/lib/supabase-browser"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
const INACTIVITY_TIMEOUT = 15 * 60 * 1000 // 15 minutes
|
||||||
|
|
||||||
|
export function AutoLogoutHandler() {
|
||||||
|
const router = useRouter()
|
||||||
|
const supabase = createClient()
|
||||||
|
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
const handleLogout = useCallback(async () => {
|
||||||
|
await supabase.auth.signOut()
|
||||||
|
toast.info("Oturumunuz uzun süre işlem yapılmadığı için sonlandırıldı.")
|
||||||
|
router.push("/login")
|
||||||
|
router.refresh()
|
||||||
|
}, [router, supabase])
|
||||||
|
|
||||||
|
const resetTimer = useCallback(() => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current)
|
||||||
|
}
|
||||||
|
timerRef.current = setTimeout(handleLogout, INACTIVITY_TIMEOUT)
|
||||||
|
}, [handleLogout])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Events to listen for
|
||||||
|
const events = [
|
||||||
|
"mousedown",
|
||||||
|
"mousemove",
|
||||||
|
"keydown",
|
||||||
|
"scroll",
|
||||||
|
"touchstart",
|
||||||
|
]
|
||||||
|
|
||||||
|
// Initial set
|
||||||
|
resetTimer()
|
||||||
|
|
||||||
|
// Event listener wrapper to debounce slightly/reset
|
||||||
|
const onUserActivity = () => {
|
||||||
|
resetTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
events.forEach((event) => {
|
||||||
|
window.addEventListener(event, onUserActivity)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current)
|
||||||
|
events.forEach((event) => {
|
||||||
|
window.removeEventListener(event, onUserActivity)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [resetTimer])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -62,8 +62,8 @@ export function CategoryForm({ initialData }: CategoryFormProps) {
|
|||||||
try {
|
try {
|
||||||
if (initialData) {
|
if (initialData) {
|
||||||
const result = await updateCategory(initialData.id, data)
|
const result = await updateCategory(initialData.id, data)
|
||||||
if ((result as any).error) {
|
if ('error' in result) {
|
||||||
toast.error((result as any).error)
|
toast.error(result.error)
|
||||||
} else {
|
} else {
|
||||||
toast.success(toastMessage)
|
toast.success(toastMessage)
|
||||||
router.push(`/dashboard/categories`)
|
router.push(`/dashboard/categories`)
|
||||||
@@ -71,15 +71,15 @@ export function CategoryForm({ initialData }: CategoryFormProps) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const result = await createCategory(data)
|
const result = await createCategory(data)
|
||||||
if ((result as any).error) {
|
if ('error' in result) {
|
||||||
toast.error((result as any).error)
|
toast.error(result.error)
|
||||||
} else {
|
} else {
|
||||||
toast.success(toastMessage)
|
toast.success(toastMessage)
|
||||||
router.push(`/dashboard/categories`)
|
router.push(`/dashboard/categories`)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error("Bir hata oluştu.")
|
toast.error("Bir hata oluştu.")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -90,14 +90,14 @@ export function CategoryForm({ initialData }: CategoryFormProps) {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const result = await deleteCategory(initialData!.id)
|
const result = await deleteCategory(initialData!.id)
|
||||||
if ((result as any).error) {
|
if ('error' in result) {
|
||||||
toast.error((result as any).error)
|
toast.error(result.error)
|
||||||
} else {
|
} else {
|
||||||
toast.success("Kategori silindi.")
|
toast.success("Kategori silindi.")
|
||||||
router.push(`/dashboard/categories`)
|
router.push(`/dashboard/categories`)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error("Silme işlemi başarısız.")
|
toast.error("Silme işlemi başarısız.")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|||||||
130
components/dashboard/content-form.tsx
Normal file
130
components/dashboard/content-form.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { SiteContent } from "@/types/cms"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
|
import { ImageUpload } from "@/components/ui/image-upload" // Ensure this exists or adapt
|
||||||
|
import { updateSiteContent } from "@/app/(dashboard)/dashboard/cms/content/actions"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Save, Loader2 } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface ContentFormProps {
|
||||||
|
initialContent: SiteContent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{ id: 'general', label: 'Genel Ayarlar' },
|
||||||
|
{ id: 'home', label: 'Anasayfa' },
|
||||||
|
{ id: 'contact', label: 'İletişim' },
|
||||||
|
{ id: 'seo', label: 'SEO Ayarları' },
|
||||||
|
{ id: 'scripts', label: 'Script & Analitik' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ContentForm({ initialContent }: ContentFormProps) {
|
||||||
|
const [contents, setContents] = useState<SiteContent[]>(initialContent)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [activeSection, setActiveSection] = useState('general')
|
||||||
|
|
||||||
|
const handleChange = (key: string, value: string) => {
|
||||||
|
setContents(prev => prev.map(item =>
|
||||||
|
item.key === key ? { ...item, value } : item
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await updateSiteContent(contents)
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("İçerikler başarıyla güncellendi")
|
||||||
|
} else {
|
||||||
|
toast.error(result.error)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Bir hata oluştu")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredContent = contents.filter(item => item.section === activeSection)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Custom Tabs */}
|
||||||
|
<div className="flex space-x-1 rounded-lg bg-muted p-1 overflow-x-auto">
|
||||||
|
{SECTIONS.map((section) => (
|
||||||
|
<button
|
||||||
|
key={section.id}
|
||||||
|
onClick={() => setActiveSection(section.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
activeSection === section.id
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:bg-background/50 hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{section.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{SECTIONS.find(s => s.id === activeSection)?.label}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Bu bölümdeki içerikleri aşağıdan düzenleyebilirsiniz.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{filteredContent.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">Bu bölümde henüz ayar bulunmuyor.</p>
|
||||||
|
)}
|
||||||
|
{filteredContent.map((item) => (
|
||||||
|
<div key={item.key} className="space-y-2">
|
||||||
|
<Label className="capitalize">
|
||||||
|
{item.key.replace(/_/g, ' ').replace(activeSection, '').trim() || item.key}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{item.type === 'image_url' ? (
|
||||||
|
<ImageUpload
|
||||||
|
value={item.value}
|
||||||
|
onChange={(url) => handleChange(item.key, url)}
|
||||||
|
onRemove={() => handleChange(item.key, '')}
|
||||||
|
/>
|
||||||
|
) : item.type === 'long_text' || item.type === 'html' || item.key.includes('address') ? (
|
||||||
|
<Textarea
|
||||||
|
value={item.value}
|
||||||
|
onChange={(e) => handleChange(item.key, e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={item.value}
|
||||||
|
onChange={(e) => handleChange(item.key, e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{item.key.includes('map_embed') && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Google Maps'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
187
components/dashboard/customer-form.tsx
Normal file
187
components/dashboard/customer-form.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import * as z from "zod"
|
||||||
|
import { Customer, CustomerInsert } from "@/types/customer"
|
||||||
|
import { addCustomer, updateCustomer } from "@/lib/customers/actions"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Plus, Loader2 } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
full_name: z.string().min(2, "Ad soyad en az 2 karakter olmalıdır"),
|
||||||
|
email: z.string().email("Geçersiz e-posta adresi").or(z.literal("")).optional(),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
address: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
interface CustomerFormProps {
|
||||||
|
customer?: Customer
|
||||||
|
trigger?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomerForm({ customer, trigger }: CustomerFormProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
full_name: customer?.full_name || "",
|
||||||
|
email: customer?.email || "",
|
||||||
|
phone: customer?.phone || "",
|
||||||
|
address: customer?.address || "",
|
||||||
|
notes: customer?.notes || "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const customerData = {
|
||||||
|
...values,
|
||||||
|
email: values.email || null,
|
||||||
|
phone: values.phone || null,
|
||||||
|
address: values.address || null,
|
||||||
|
notes: values.notes || null,
|
||||||
|
} as CustomerInsert
|
||||||
|
|
||||||
|
let result
|
||||||
|
if (customer) {
|
||||||
|
result = await updateCustomer(customer.id, customerData)
|
||||||
|
} else {
|
||||||
|
result = await addCustomer(customerData)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(customer ? "Müşteri güncellendi" : "Müşteri eklendi")
|
||||||
|
setOpen(false)
|
||||||
|
form.reset()
|
||||||
|
} else {
|
||||||
|
toast.error(result.error)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Bir hata oluştu")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger || (
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> Yeni Müşteri
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{customer ? "Müşteriyi Düzenle" : "Yeni Müşteri Ekle"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Müşteri bilgilerini aşağıdan yönetebilirsiniz.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="full_name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Ad Soyad</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Ad Soyad" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>E-Posta</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="ornek@site.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="phone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Telefon</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="0555..." {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="address"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Adres</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea placeholder="Adres..." {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="notes"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Özel Notlar</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea placeholder="Notlar..." {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Kaydet
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,7 +6,12 @@ import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
|||||||
import { Sidebar } from "@/components/dashboard/sidebar"
|
import { Sidebar } from "@/components/dashboard/sidebar"
|
||||||
import { UserNav } from "@/components/dashboard/user-nav"
|
import { UserNav } from "@/components/dashboard/user-nav"
|
||||||
|
|
||||||
export function DashboardHeader() {
|
interface DashboardHeaderProps {
|
||||||
|
user: { email?: string | null } | null
|
||||||
|
profile: { full_name?: string | null, role?: string | null } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardHeader({ user, profile }: DashboardHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-30 flex h-14 items-center gap-4 border-b bg-background px-4 sm:static sm:h-auto sm:border-0 sm:bg-transparent sm:px-6">
|
<header className="sticky top-0 z-30 flex h-14 items-center gap-4 border-b bg-background px-4 sm:static sm:h-auto sm:border-0 sm:bg-transparent sm:px-6">
|
||||||
<Sheet>
|
<Sheet>
|
||||||
@@ -28,7 +33,7 @@ export function DashboardHeader() {
|
|||||||
<div className="w-full flex-1">
|
<div className="w-full flex-1">
|
||||||
{/* Breadcrumb or Search could go here */}
|
{/* Breadcrumb or Search could go here */}
|
||||||
</div>
|
</div>
|
||||||
<UserNav />
|
<UserNav user={user} profile={profile} />
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
113
components/dashboard/password-form.tsx
Normal file
113
components/dashboard/password-form.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import * as z from "zod"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
import { supabase } from "@/lib/supabase"
|
||||||
|
|
||||||
|
const passwordSchema = z.object({
|
||||||
|
password: z.string().min(6, "Şifre en az 6 karakter olmalıdır."),
|
||||||
|
confirmPassword: z.string().min(6, "Şifre tekrarı en az 6 karakter olmalıdır."),
|
||||||
|
}).refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: "Şifreler eşleşmiyor.",
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
})
|
||||||
|
|
||||||
|
type PasswordFormValues = z.infer<typeof passwordSchema>
|
||||||
|
|
||||||
|
export function PasswordForm() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const form = useForm<PasswordFormValues>({
|
||||||
|
resolver: zodResolver(passwordSchema),
|
||||||
|
defaultValues: {
|
||||||
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = async (data: PasswordFormValues) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.auth.updateUser({
|
||||||
|
password: data.password
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error("Şifre güncellenemedi: " + error.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Şifreniz başarıyla güncellendi.")
|
||||||
|
form.reset()
|
||||||
|
router.refresh()
|
||||||
|
} catch {
|
||||||
|
toast.error("Bir sorun oluştu.")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Yeni Şifre Belirle</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Hesabınız için yeni bir şifre belirleyin.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Yeni Şifre</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="******" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="confirmPassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Şifre Tekrar</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="******" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Şifreyi Güncelle
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm, type Resolver } from "react-hook-form"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import * as z from "zod"
|
import * as z from "zod"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -23,31 +23,44 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Loader2 } from "lucide-react"
|
import { Loader2, X, UploadCloud } from "lucide-react"
|
||||||
|
import imageCompression from 'browser-image-compression'
|
||||||
|
import { createClient } from "@/lib/supabase-browser"
|
||||||
|
import Image from "next/image"
|
||||||
|
|
||||||
|
import { createProduct, updateProduct } from "@/app/(dashboard)/dashboard/products/actions"
|
||||||
|
|
||||||
const productSchema = z.object({
|
const productSchema = z.object({
|
||||||
name: z.string().min(2, "Ürün adı en az 2 karakter olmalıdır"),
|
name: z.string().min(2, "Ürün adı en az 2 karakter olmalıdır"),
|
||||||
|
product_code: z.string().optional(),
|
||||||
category: z.string().min(1, "Kategori seçiniz"),
|
category: z.string().min(1, "Kategori seçiniz"),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
price: z.coerce.number().min(0, "Fiyat 0'dan küçük olamaz"),
|
price: z.coerce.number().min(0, "Fiyat 0'dan küçük olamaz"),
|
||||||
image_url: z.string().optional(),
|
image_url: z.string().optional(),
|
||||||
|
is_active: z.boolean().default(true),
|
||||||
|
images: z.array(z.string()).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
type ProductFormValues = z.infer<typeof productSchema>
|
type ProductFormValues = z.infer<typeof productSchema>
|
||||||
|
|
||||||
import { createProduct, updateProduct } from "@/app/(dashboard)/dashboard/products/actions"
|
|
||||||
|
|
||||||
// Define the shape of data coming from Supabase
|
// Define the shape of data coming from Supabase
|
||||||
interface Product {
|
interface Product {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
product_code?: string | null
|
||||||
category: string
|
category: string
|
||||||
description: string | null
|
description: string | null
|
||||||
price: number
|
price: number
|
||||||
image_url: string | null
|
image_url: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
|
is_active?: boolean
|
||||||
|
// images? we might need to fetch them separately if they are in another table,
|
||||||
|
// but for now let's assume update passes them if fetched, or we can handle it later.
|
||||||
|
// Ideally the server component fetches relation.
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductFormProps {
|
interface ProductFormProps {
|
||||||
@@ -57,24 +70,120 @@ interface ProductFormProps {
|
|||||||
export function ProductForm({ initialData }: ProductFormProps) {
|
export function ProductForm({ initialData }: ProductFormProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [previewImages, setPreviewImages] = useState<string[]>(
|
||||||
|
initialData?.image_url ? [initialData.image_url] : []
|
||||||
|
)
|
||||||
|
// Note: initialData probably only has single image_url field unless we updated the fetch query.
|
||||||
|
// For MVP phase 1, we just sync with image_url or expect 'images' prop if we extended it.
|
||||||
|
// I will add a local state for images.
|
||||||
|
|
||||||
const form = useForm<ProductFormValues>({
|
const form = useForm<ProductFormValues>({
|
||||||
resolver: zodResolver(productSchema) as any,
|
resolver: zodResolver(productSchema) as Resolver<ProductFormValues>,
|
||||||
defaultValues: initialData ? {
|
defaultValues: initialData ? {
|
||||||
name: initialData.name,
|
name: initialData.name,
|
||||||
|
product_code: initialData.product_code || "",
|
||||||
category: initialData.category,
|
category: initialData.category,
|
||||||
description: initialData.description || "",
|
description: initialData.description || "",
|
||||||
price: initialData.price,
|
price: initialData.price,
|
||||||
image_url: initialData.image_url || "",
|
image_url: initialData.image_url || "",
|
||||||
|
is_active: initialData.is_active ?? true,
|
||||||
|
images: initialData.image_url ? [initialData.image_url] : []
|
||||||
} : {
|
} : {
|
||||||
name: "",
|
name: "",
|
||||||
|
product_code: "",
|
||||||
category: "",
|
category: "",
|
||||||
description: "",
|
description: "",
|
||||||
price: 0,
|
price: 0,
|
||||||
image_url: "",
|
image_url: "",
|
||||||
|
is_active: true,
|
||||||
|
images: []
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = event.target.files
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
|
||||||
|
setUploading(true)
|
||||||
|
const supabase = createClient()
|
||||||
|
const uploadedUrls: string[] = [...form.getValues("images") || []]
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i]
|
||||||
|
|
||||||
|
// Compression
|
||||||
|
const options = {
|
||||||
|
maxSizeMB: 1, // Max 1MB
|
||||||
|
maxWidthOrHeight: 1920,
|
||||||
|
useWebWorker: true
|
||||||
|
}
|
||||||
|
|
||||||
|
let compressedFile = file
|
||||||
|
try {
|
||||||
|
compressedFile = await imageCompression(file, options)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Compression error:", error)
|
||||||
|
// Fallback to original
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
const fileExt = file.name.split('.').pop()
|
||||||
|
const fileName = `${Math.random().toString(36).substring(2)}_${Date.now()}.${fileExt}`
|
||||||
|
const filePath = `products/${fileName}`
|
||||||
|
|
||||||
|
const { error: uploadError } = await supabase.storage
|
||||||
|
.from('products') // Assuming 'products' bucket exists
|
||||||
|
.upload(filePath, compressedFile)
|
||||||
|
|
||||||
|
if (uploadError) {
|
||||||
|
console.error(uploadError)
|
||||||
|
toast.error(`Resim yüklenemedi: ${file.name}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get URL
|
||||||
|
const { data } = supabase.storage.from('products').getPublicUrl(filePath)
|
||||||
|
uploadedUrls.push(data.publicUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update form
|
||||||
|
form.setValue("images", uploadedUrls)
|
||||||
|
// Set first image as main
|
||||||
|
if (uploadedUrls.length > 0) {
|
||||||
|
form.setValue("image_url", uploadedUrls[0])
|
||||||
|
}
|
||||||
|
setPreviewImages(uploadedUrls)
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
toast.error("Yükleme sırasında hata oluştu")
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeImage = async (index: number) => {
|
||||||
|
const imageToDelete = previewImages[index]
|
||||||
|
|
||||||
|
// If editing an existing product and image is from server (starts with http/https usually)
|
||||||
|
// Local state removal only. Server update happens on Save.
|
||||||
|
// if (initialData && imageToDelete.startsWith("http")) { ... }
|
||||||
|
|
||||||
|
const currentImages = [...form.getValues("images") || []]
|
||||||
|
// Filter out the deleted image URL if it matches
|
||||||
|
const newImages = currentImages.filter(url => url !== imageToDelete)
|
||||||
|
|
||||||
|
form.setValue("images", newImages)
|
||||||
|
|
||||||
|
if (newImages.length > 0) {
|
||||||
|
form.setValue("image_url", newImages[0])
|
||||||
|
} else {
|
||||||
|
form.setValue("image_url", "")
|
||||||
|
}
|
||||||
|
setPreviewImages(newImages)
|
||||||
|
}
|
||||||
|
|
||||||
async function onSubmit(data: ProductFormValues) {
|
async function onSubmit(data: ProductFormValues) {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -94,7 +203,7 @@ export function ProductForm({ initialData }: ProductFormProps) {
|
|||||||
toast.success(initialData ? "Ürün güncellendi" : "Ürün başarıyla oluşturuldu")
|
toast.success(initialData ? "Ürün güncellendi" : "Ürün başarıyla oluşturuldu")
|
||||||
router.push("/dashboard/products")
|
router.push("/dashboard/products")
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error("Bir aksilik oldu")
|
toast.error("Bir aksilik oldu")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -104,6 +213,31 @@ export function ProductForm({ initialData }: ProductFormProps) {
|
|||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 w-full max-w-2xl">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 w-full max-w-2xl">
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="is_active"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4 shadow-sm w-full max-w-xs">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel className="text-base">Aktif Durum</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Ürün sitede görüntülensin mi?
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
@@ -117,8 +251,22 @@ export function ProductForm({ initialData }: ProductFormProps) {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="product_code"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Ürün Kodu</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="KOD-123" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="category"
|
name="category"
|
||||||
@@ -143,6 +291,7 @@ export function ProductForm({ initialData }: ProductFormProps) {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -157,24 +306,54 @@ export function ProductForm({ initialData }: ProductFormProps) {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormLabel>Ürün Görselleri</FormLabel>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{previewImages.map((url, i) => (
|
||||||
|
<div key={i} className="relative aspect-square border rounded-md overflow-hidden group">
|
||||||
|
<Image
|
||||||
|
src={url}
|
||||||
|
alt="Preview"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeImage(i)}
|
||||||
|
className="absolute top-1 right-1 bg-red-500 text-white p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<label className="flex flex-col items-center justify-center w-full aspect-square border-2 border-dashed rounded-lg cursor-pointer hover:bg-muted/50 transition-colors">
|
||||||
|
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||||
|
{uploading ? (
|
||||||
|
<Loader2 className="w-8 h-8 text-gray-500 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<UploadCloud className="w-8 h-8 text-gray-500" />
|
||||||
|
)}
|
||||||
|
<p className="mb-2 text-sm text-gray-500">Resim Yükle</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
multiple
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<FormDescription>
|
||||||
|
Birden fazla resim seçebilirsiniz. Resimler otomatik olarak sıkıştırılacaktır.
|
||||||
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField
|
{/* Hidden input for main image url fallback if needed */}
|
||||||
control={form.control}
|
<input type="hidden" {...form.register("image_url")} />
|
||||||
name="image_url"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Görsel URL (Opsiyonel)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="https://..." {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Ürün görseli için şimdilik dış bağlantı kullanın.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -190,8 +369,8 @@ export function ProductForm({ initialData }: ProductFormProps) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit" disabled={loading || uploading}>
|
||||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{(loading || uploading) && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
{initialData ? "Güncelle" : "Oluştur"}
|
{initialData ? "Güncelle" : "Oluştur"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
80
components/dashboard/settings-tabs.tsx
Normal file
80
components/dashboard/settings-tabs.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import { SmsSettingsForm } from "@/components/dashboard/sms-settings-form"
|
||||||
|
import { AppearanceForm } from "@/components/dashboard/appearance-form"
|
||||||
|
import { UsersTable, Profile } from "@/components/dashboard/users-table"
|
||||||
|
import { ContentForm } from "@/components/dashboard/content-form"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { SiteContent } from "@/types/cms"
|
||||||
|
|
||||||
|
interface SettingsTabsProps {
|
||||||
|
smsSettings: {
|
||||||
|
username: string
|
||||||
|
header: string
|
||||||
|
} | null
|
||||||
|
users: Profile[]
|
||||||
|
contents: SiteContent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsTabs({ smsSettings, users, contents }: SettingsTabsProps) {
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="content" className="space-y-4">
|
||||||
|
<TabsList className="grid w-full grid-cols-2 sm:grid-cols-3 md:grid-cols-5 h-auto gap-1">
|
||||||
|
<TabsTrigger value="content">İçerik Yönetimi</TabsTrigger>
|
||||||
|
<TabsTrigger value="users">Kullanıcılar</TabsTrigger>
|
||||||
|
<TabsTrigger value="sms">SMS / Bildirimler</TabsTrigger>
|
||||||
|
<TabsTrigger value="appearance">Görünüm</TabsTrigger>
|
||||||
|
<TabsTrigger value="security">Güvenlik</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="content" className="space-y-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium">Site İçerik Yönetimi</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Site genel ayarları, iletişim bilgileri ve logolar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ContentForm initialContent={contents} />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="users" className="space-y-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium">Kullanıcı Yönetimi</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Sistemdeki kayıtlı kullanıcıları ve rollerini yönetin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UsersTable users={users} />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="sms" className="space-y-4">
|
||||||
|
<SmsSettingsForm initialData={smsSettings} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="appearance" className="space-y-4">
|
||||||
|
<AppearanceForm />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="security" className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Hesap Güvenliği</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Şifre ve oturum yönetimi.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Button variant="outline" className="w-full">Şifre Değiştir</Button>
|
||||||
|
<Button variant="destructive" className="w-full">Hesabı Sil</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { LayoutDashboard, Package, ShoppingCart, Users, Settings, Globe, Tags } from "lucide-react"
|
import { LayoutDashboard, Package, ShoppingCart, Settings, Globe, Tags, Users, MessageSquare, History } from "lucide-react"
|
||||||
|
|
||||||
const sidebarItems = [
|
const sidebarItems = [
|
||||||
{
|
{
|
||||||
@@ -16,21 +16,26 @@ const sidebarItems = [
|
|||||||
href: "/dashboard/products",
|
href: "/dashboard/products",
|
||||||
icon: Package,
|
icon: Package,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Siparişler",
|
|
||||||
href: "/dashboard/orders",
|
|
||||||
icon: ShoppingCart,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Kategoriler",
|
title: "Kategoriler",
|
||||||
href: "/dashboard/categories",
|
href: "/dashboard/categories",
|
||||||
icon: Tags,
|
icon: Tags,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Kullanıcılar",
|
title: "Siparişler",
|
||||||
href: "/dashboard/users",
|
href: "/dashboard/orders",
|
||||||
|
icon: ShoppingCart,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Müşteriler",
|
||||||
|
href: "/dashboard/customers",
|
||||||
icon: Users,
|
icon: Users,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "SMS Gönder",
|
||||||
|
href: "/dashboard/sms",
|
||||||
|
icon: MessageSquare,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Ayarlar",
|
title: "Ayarlar",
|
||||||
href: "/dashboard/settings",
|
href: "/dashboard/settings",
|
||||||
@@ -43,7 +48,7 @@ const sidebarItems = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> { }
|
type SidebarProps = React.HTMLAttributes<HTMLDivElement>
|
||||||
|
|
||||||
export function Sidebar({ className }: SidebarProps) {
|
export function Sidebar({ className }: SidebarProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
@@ -56,7 +61,7 @@ export function Sidebar({ className }: SidebarProps) {
|
|||||||
Yönetim
|
Yönetim
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{sidebarItems.map((item) => (
|
{sidebarItems.filter(i => !i.href.includes('/sms')).map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
@@ -71,6 +76,34 @@ export function Sidebar({ className }: SidebarProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<h2 className="mb-2 px-4 text-lg font-semibold tracking-tight">
|
||||||
|
SMS
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/sms"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors",
|
||||||
|
pathname === "/dashboard/sms" ? "bg-slate-100 dark:bg-slate-800 text-primary" : "text-slate-500 dark:text-slate-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MessageSquare className="mr-2 h-4 w-4" />
|
||||||
|
Yeni SMS
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/sms/logs"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors",
|
||||||
|
pathname === "/dashboard/sms/logs" ? "bg-slate-100 dark:bg-slate-800 text-primary" : "text-slate-500 dark:text-slate-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<History className="mr-2 h-4 w-4" />
|
||||||
|
Geçmiş
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { useForm } from "react-hook-form"
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
|
||||||
import * as z from "zod"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form"
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { toast } from "sonner"
|
|
||||||
import { Loader2 } from "lucide-react"
|
|
||||||
import { updateSiteSettings } from "@/app/(dashboard)/dashboard/settings/actions"
|
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
|
||||||
site_title: z.string().min(2, "Site başlığı en az 2 karakter olmalıdır."),
|
|
||||||
site_description: z.string(),
|
|
||||||
contact_email: z.literal("").or(z.string().email("Geçerli bir e-posta adresi giriniz.")),
|
|
||||||
contact_phone: z.string(),
|
|
||||||
currency: z.string(),
|
|
||||||
})
|
|
||||||
|
|
||||||
type SettingsFormValues = z.infer<typeof settingsSchema>
|
|
||||||
|
|
||||||
interface SiteSettingsFormProps {
|
|
||||||
initialData: any
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SiteSettingsForm({ initialData }: SiteSettingsFormProps) {
|
|
||||||
const router = useRouter()
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
const form = useForm<SettingsFormValues>({
|
|
||||||
resolver: zodResolver(settingsSchema),
|
|
||||||
defaultValues: {
|
|
||||||
site_title: initialData?.site_title || "ParaKasa",
|
|
||||||
site_description: initialData?.site_description || "",
|
|
||||||
contact_email: initialData?.contact_email || "",
|
|
||||||
contact_phone: initialData?.contact_phone || "",
|
|
||||||
currency: initialData?.currency || "TRY",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const onSubmit = async (data: SettingsFormValues) => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
// @ts-ignore
|
|
||||||
const result = await updateSiteSettings(data)
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
toast.error(result.error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("Site ayarları güncellendi.")
|
|
||||||
router.refresh()
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Bir sorun oluştu.")
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Genel Ayarlar</CardTitle>
|
|
||||||
<CardDescription>Web sitesinin genel yapılandırma ayarları.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="site_title"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Site Başlığı</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="ParaKasa" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>Tarayıcı sekmesinde görünen ad.</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="site_description"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Site 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
213
components/dashboard/slider-form.tsx
Normal file
213
components/dashboard/slider-form.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useForm, type Resolver } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import * as z from "zod"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
import { ImageUpload } from "@/components/ui/image-upload"
|
||||||
|
import { createSlider, updateSlider } from "@/app/(dashboard)/dashboard/sliders/actions"
|
||||||
|
|
||||||
|
const sliderSchema = z.object({
|
||||||
|
title: z.string().min(2, "Başlık en az 2 karakter olmalıdır"),
|
||||||
|
description: z.string().optional(),
|
||||||
|
image_url: z.string().min(1, "Görsel yüklemek zorunludur"),
|
||||||
|
link: z.string().optional(),
|
||||||
|
order: z.coerce.number().default(0),
|
||||||
|
is_active: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
|
||||||
|
type SliderFormValues = z.infer<typeof sliderSchema>
|
||||||
|
|
||||||
|
interface Slider {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
image_url: string
|
||||||
|
link: string | null
|
||||||
|
order: number | null
|
||||||
|
is_active: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SliderFormProps {
|
||||||
|
initialData?: Slider
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SliderForm({ initialData }: SliderFormProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const form = useForm<SliderFormValues>({
|
||||||
|
resolver: zodResolver(sliderSchema) as Resolver<SliderFormValues>,
|
||||||
|
defaultValues: initialData ? {
|
||||||
|
title: initialData.title,
|
||||||
|
description: initialData.description || "",
|
||||||
|
image_url: initialData.image_url,
|
||||||
|
link: initialData.link || "",
|
||||||
|
order: initialData.order || 0,
|
||||||
|
is_active: initialData.is_active ?? true,
|
||||||
|
} : {
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
image_url: "",
|
||||||
|
link: "",
|
||||||
|
order: 0,
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onSubmit(data: SliderFormValues) {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
let result
|
||||||
|
if (initialData) {
|
||||||
|
result = await updateSlider(initialData.id, data)
|
||||||
|
} else {
|
||||||
|
result = await createSlider(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
toast.error(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(initialData ? "Slider güncellendi" : "Slider oluşturuldu")
|
||||||
|
router.push("/dashboard/sliders")
|
||||||
|
router.refresh()
|
||||||
|
} catch {
|
||||||
|
toast.error("Bir sorun oluştu.")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{initialData ? "Slider Düzenle" : "Yeni Slider Ekle"}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="image_url"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Görsel</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<ImageUpload
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onRemove={() => field.onChange("")}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Başlık</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Örn: Yeni Sezon Modelleri" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="order"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Sıralama</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Düşük numara önce gösterilir (0, 1, 2...)</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>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>
|
||||||
|
)
|
||||||
|
}
|
||||||
480
components/dashboard/sms-page-client.tsx
Normal file
480
components/dashboard/sms-page-client.tsx
Normal file
@@ -0,0 +1,480 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import * as z from "zod"
|
||||||
|
import { Customer } from "@/types/customer"
|
||||||
|
import { sendBulkSms } from "@/lib/sms/actions"
|
||||||
|
import { getTemplates, createTemplate, deleteTemplate, SmsTemplate } from "@/lib/sms/templates"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Loader2, Send, Save, Trash2, BookOpen, Smartphone } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
manualNumbers: z.string().optional(),
|
||||||
|
message: z.string().min(1, "Mesaj içeriği boş olamaz").max(900, "Mesaj çok uzun (max 900 karakter)"),
|
||||||
|
selectedCustomers: z.array(z.string()).optional()
|
||||||
|
})
|
||||||
|
|
||||||
|
interface SmsPageProps {
|
||||||
|
customers: Customer[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SmsPageClient({ customers }: SmsPageProps) {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [templates, setTemplates] = useState<SmsTemplate[]>([])
|
||||||
|
|
||||||
|
// Template Management States
|
||||||
|
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false)
|
||||||
|
const [newTemplateTitle, setNewTemplateTitle] = useState("")
|
||||||
|
const [newTemplateMessage, setNewTemplateMessage] = useState("")
|
||||||
|
const [templateLoading, setTemplateLoading] = useState(false)
|
||||||
|
|
||||||
|
// Contact Picker States
|
||||||
|
const [isContactModalOpen, setIsContactModalOpen] = useState(false)
|
||||||
|
const [searchTerm, setSearchTerm] = useState("")
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
manualNumbers: "",
|
||||||
|
message: "",
|
||||||
|
selectedCustomers: []
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleNativeContactPicker() {
|
||||||
|
if (!('contacts' in navigator && 'ContactsManager' in window)) {
|
||||||
|
toast.error("Rehber özelliği desteklenmiyor (HTTPS gerekli olabilir).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const props = ['tel'];
|
||||||
|
const opts = { multiple: true };
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const contacts = await (navigator as any).contacts.select(props, opts);
|
||||||
|
|
||||||
|
if (contacts && contacts.length > 0) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const newNumbers = contacts.map((contact: any) => {
|
||||||
|
const phone = contact.tel?.[0]
|
||||||
|
return phone ? phone.replace(/\s/g, '') : null;
|
||||||
|
}).filter(Boolean).join(", ");
|
||||||
|
|
||||||
|
if (newNumbers) {
|
||||||
|
const current = form.getValues("manualNumbers");
|
||||||
|
const updated = current ? `${current}, ${newNumbers}` : newNumbers;
|
||||||
|
form.setValue("manualNumbers", updated);
|
||||||
|
toast.success(`${contacts.length} numara eklendi.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
console.error(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load templates on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadTemplates()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function loadTemplates() {
|
||||||
|
const result = await getTemplates()
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setTemplates(result.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveTemplate() {
|
||||||
|
if (!newTemplateTitle || !newTemplateMessage) {
|
||||||
|
toast.error("Başlık ve mesaj zorunludur")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setTemplateLoading(true)
|
||||||
|
const result = await createTemplate(newTemplateTitle, newTemplateMessage)
|
||||||
|
setTemplateLoading(false)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Şablon kaydedildi")
|
||||||
|
setNewTemplateTitle("")
|
||||||
|
setNewTemplateMessage("")
|
||||||
|
setIsTemplateModalOpen(false)
|
||||||
|
loadTemplates()
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Şablon kaydedilemedi")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteTemplate(id: string) {
|
||||||
|
if (!confirm("Bu şablonu silmek istediğinize emin misiniz?")) return
|
||||||
|
|
||||||
|
const result = await deleteTemplate(id)
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Şablon silindi")
|
||||||
|
loadTemplates()
|
||||||
|
} else {
|
||||||
|
toast.error("Şablon silinemedi")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectTemplate = (id: string) => {
|
||||||
|
const template = templates.find(t => t.id === id)
|
||||||
|
if (template) {
|
||||||
|
form.setValue("message", template.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter customers for contact picker
|
||||||
|
const filteredCustomers = customers.filter(c =>
|
||||||
|
c.full_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
c.phone?.includes(searchTerm)
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleCustomerSelection = (phone: string) => {
|
||||||
|
const current = form.getValues("selectedCustomers") || []
|
||||||
|
if (current.includes(phone)) {
|
||||||
|
form.setValue("selectedCustomers", current.filter(p => p !== phone))
|
||||||
|
} else {
|
||||||
|
form.setValue("selectedCustomers", [...current, phone])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAllFiltered = () => {
|
||||||
|
const current = form.getValues("selectedCustomers") || []
|
||||||
|
const newPhones = filteredCustomers.map(c => c.phone).filter(Boolean) as string[]
|
||||||
|
// Merge unique
|
||||||
|
const merged = Array.from(new Set([...current, ...newPhones]))
|
||||||
|
form.setValue("selectedCustomers", merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deselectAll = () => {
|
||||||
|
form.setValue("selectedCustomers", [])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
const manualPhones = values.manualNumbers
|
||||||
|
?.split(/[,\n]/)
|
||||||
|
.map(p => p.trim())
|
||||||
|
.filter(p => p !== "") || []
|
||||||
|
|
||||||
|
const customerPhones = values.selectedCustomers || []
|
||||||
|
const allPhones = [...manualPhones, ...customerPhones]
|
||||||
|
|
||||||
|
if (allPhones.length === 0) {
|
||||||
|
toast.error("Lütfen en az bir alıcı seçin veya numara girin.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await sendBulkSms(allPhones, values.message)
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message)
|
||||||
|
form.reset({
|
||||||
|
manualNumbers: "",
|
||||||
|
message: "",
|
||||||
|
selectedCustomers: []
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "SMS gönderilirken hata oluştu")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Bir hata oluştu")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const watchedSelected = form.watch("selectedCustomers") || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">SMS Gönderimi</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<Card className="md:col-span-2 lg:col-span-1">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Mesaj Bilgileri</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Toplu veya tekil SMS gönderin. (Türkçe karakter desteklenir)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form id="sms-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{/* Templates Section */}
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Label>Hazır Şablonlar</Label>
|
||||||
|
<Select onValueChange={handleSelectTemplate}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Şablon seçin..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{templates.map(t => (
|
||||||
|
<SelectItem key={t.id} value={t.id}>{t.title}</SelectItem>
|
||||||
|
))}
|
||||||
|
{templates.length === 0 && <div className="p-2 text-sm text-muted-foreground">Henüz şablon yok</div>}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Dialog open={isTemplateModalOpen} onOpenChange={setIsTemplateModalOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button type="button" variant="outline" size="icon">
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Yeni SMS Şablonu</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Sık kullandığınız mesajları şablon olarak kaydedin.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Şablon Adı</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Örn: Bayram Kutlaması"
|
||||||
|
value={newTemplateTitle}
|
||||||
|
onChange={e => setNewTemplateTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Mesaj İçeriği</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Mesajınız..."
|
||||||
|
value={newTemplateMessage}
|
||||||
|
onChange={e => setNewTemplateMessage(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{templates.length > 0 && (
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<h4 className="text-sm font-medium mb-2">Kayıtlı Şablonlar</h4>
|
||||||
|
<ScrollArea className="h-32 rounded border p-2">
|
||||||
|
{templates.map(t => (
|
||||||
|
<div key={t.id} className="flex items-center justify-between text-sm py-1">
|
||||||
|
<span>{t.title}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 text-red-500"
|
||||||
|
onClick={() => handleDeleteTemplate(t.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setIsTemplateModalOpen(false)}>İptal</Button>
|
||||||
|
<Button type="button" onClick={handleSaveTemplate} disabled={templateLoading}>Kaydet</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel label="Gönderilecek Mesaj" />
|
||||||
|
<Textarea
|
||||||
|
className="min-h-[120px]"
|
||||||
|
placeholder="Mesajınızı buraya yazın..."
|
||||||
|
{...form.register("message")}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>Türkçe karakterler otomatik desteklenir.</span>
|
||||||
|
<span>{form.watch("message")?.length || 0} / 900</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>Manuel Numaralar</Label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
onClick={handleNativeContactPicker}
|
||||||
|
>
|
||||||
|
<Smartphone className="mr-2 h-4 w-4" />
|
||||||
|
Telefondan Seç
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
placeholder="5551234567 (Her satıra bir numara)"
|
||||||
|
className="min-h-[80px]"
|
||||||
|
{...form.register("manualNumbers")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<div className="p-4 bg-slate-50 dark:bg-slate-900 rounded-md">
|
||||||
|
<h4 className="font-semibold mb-2">Özet</h4>
|
||||||
|
<ul className="list-disc list-inside text-sm">
|
||||||
|
<li>Manuel: {(form.watch("manualNumbers")?.split(/[,\n]/).filter(x => x.trim()).length || 0)} Kişi</li>
|
||||||
|
<li>Seçili Müşteri: {watchedSelected.length} Kişi</li>
|
||||||
|
<li className="font-bold mt-1">Toplam: {(form.watch("manualNumbers")?.split(/[,\n]/).filter(x => x.trim()).length || 0) + watchedSelected.length} Kişi</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button form="sms-form" type="submit" disabled={loading} className="w-full">
|
||||||
|
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
||||||
|
Gönderimi Başlat
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Contact Picker Section */}
|
||||||
|
<Card className="md:col-span-2 lg:col-span-1 h-fit">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center justify-between">
|
||||||
|
<span>Müşteri Rehberi</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Dialog open={isContactModalOpen} onOpenChange={setIsContactModalOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<BookOpen className="mr-2 h-4 w-4" />
|
||||||
|
Rehberi 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>
|
||||||
|
}
|
||||||
198
components/dashboard/sms-settings-form.tsx
Normal file
198
components/dashboard/sms-settings-form.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import * as z from "zod"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Loader2, Send } from "lucide-react"
|
||||||
|
import { updateSmsSettings, sendTestSms } from "@/lib/sms/actions"
|
||||||
|
|
||||||
|
const smsSettingsSchema = z.object({
|
||||||
|
username: z.string().min(1, "Kullanıcı adı gereklidir."),
|
||||||
|
password: z.string().optional(),
|
||||||
|
header: z.string().min(1, "Başlık (Gönderici Adı) gereklidir."),
|
||||||
|
})
|
||||||
|
|
||||||
|
type SmsSettingsValues = z.infer<typeof smsSettingsSchema>
|
||||||
|
|
||||||
|
interface SmsSettingsFormProps {
|
||||||
|
initialData: {
|
||||||
|
username: string
|
||||||
|
header: string
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SmsSettingsForm({ initialData }: SmsSettingsFormProps) {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [testLoading, setTestLoading] = useState(false)
|
||||||
|
const [testPhone, setTestPhone] = useState("")
|
||||||
|
|
||||||
|
const form = useForm<SmsSettingsValues>({
|
||||||
|
resolver: zodResolver(smsSettingsSchema),
|
||||||
|
defaultValues: {
|
||||||
|
username: initialData?.username || "",
|
||||||
|
header: initialData?.header || "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = async (data: SmsSettingsValues) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await updateSmsSettings({
|
||||||
|
username: data.username,
|
||||||
|
password: data.password,
|
||||||
|
header: data.header,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
toast.error(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("SMS ayarları güncellendi.")
|
||||||
|
// Don't reset form fully, keeps values visible except password
|
||||||
|
form.setValue("password", "")
|
||||||
|
} catch {
|
||||||
|
toast.error("Bir sorun oluştu.")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTestSms = async () => {
|
||||||
|
if (!testPhone) {
|
||||||
|
toast.error("Lütfen bir test numarası girin.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setTestLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await sendTestSms(testPhone)
|
||||||
|
if (result.error) {
|
||||||
|
toast.error("Test başarısız: " + result.error)
|
||||||
|
} else {
|
||||||
|
toast.success("Test SMS başarıyla gönderildi!")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("Test sırasında bir hata oluştu.")
|
||||||
|
} finally {
|
||||||
|
setTestLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>NetGSM Konfigürasyonu</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
NetGSM API bilgilerinizi buradan yönetebilirsiniz. Şifre alanı sadece değiştirmek istediğinizde gereklidir.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>NetGSM Kullanıcı Adı (850...)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="850xxxxxxx" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Şifre</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="••••••••" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Mevcut şifreyi korumak için boş bırakın.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="header"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Mesaj Başlığı (Gönderici Adı)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="PARAKASA" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
NetGSM panelinde tanımlı gönderici adınız.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Ayarları Kaydet
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Bağlantı Testi</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Ayarların doğru çalıştığını doğrulamak için test SMS gönderin.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="grid gap-2 flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder="5551234567"
|
||||||
|
value={testPhone}
|
||||||
|
onChange={(e) => setTestPhone(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-[0.8rem] text-muted-foreground">
|
||||||
|
Başında 0 olmadan 10 hane giriniz.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" onClick={onTestSms} disabled={testLoading || !testPhone}>
|
||||||
|
{testLoading ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Test Gönder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import * as z from "zod"
|
import * as z from "zod"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { PhoneInput } from "@/components/ui/phone-input"
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -25,35 +26,59 @@ import {
|
|||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Loader2 } from "lucide-react"
|
import { Loader2 } from "lucide-react"
|
||||||
import { createUser, updateUser } from "@/app/(dashboard)/dashboard/users/actions"
|
import { createUser, updateUser, updateProfile } from "@/app/(dashboard)/dashboard/users/actions"
|
||||||
|
|
||||||
const userSchema = z.object({
|
const userSchema = z.object({
|
||||||
firstName: z.string().min(2, "Ad en az 2 karakter olmalıdır."),
|
firstName: z.string().min(2, "Ad en az 2 karakter olmalıdır."),
|
||||||
lastName: z.string().min(2, "Soyad en az 2 karakter olmalıdır."),
|
lastName: z.string().min(2, "Soyad en az 2 karakter olmalıdır."),
|
||||||
email: z.string().email("Geçerli bir e-posta adresi giriniz."),
|
email: z.string().email("Geçerli bir e-posta adresi giriniz."),
|
||||||
password: z.string().optional(), // Password is optional on edit
|
password: z.string().optional(),
|
||||||
|
confirmPassword: z.string().optional(),
|
||||||
role: z.enum(["admin", "user"]),
|
role: z.enum(["admin", "user"]),
|
||||||
}).refine((data) => {
|
phone: z.string().optional(),
|
||||||
// If we are creating a NEW user (no ID passed in props effectively, but schema doesn't know props),
|
}).superRefine((data, ctx) => {
|
||||||
// we generally want password required. But here we'll handle it in the component logic or strictly separate schemas.
|
// 1. Password match check
|
||||||
// For simplicity, we make password optional in Zod but check it in onSubmit if it's a create action.
|
if (data.password && data.password !== data.confirmPassword) {
|
||||||
return true
|
ctx.addIssue({
|
||||||
})
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Şifreler eşleşmiyor.",
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. New user password requirement check
|
||||||
|
// If there is no ID (we can't easily check for ID here in schema without context,
|
||||||
|
// but typically empty password on CREATE is invalid unless handled elsewhere.
|
||||||
|
// However, the component logic implies 'initialData' determines edit/create mode.
|
||||||
|
// For pure schema validation, we often make password required for create, optional for edit.
|
||||||
|
// Since we don't pass 'isCreate' to schema, we can enforce minimum length if provided.
|
||||||
|
if (data.password && data.password.length > 0 && data.password.length < 6) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Şifre en az 6 karakter olmalıdır.",
|
||||||
|
path: ["password"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
type UserFormValues = z.infer<typeof userSchema>
|
type UserFormValues = z.infer<typeof userSchema>
|
||||||
|
|
||||||
interface UserFormProps {
|
interface UserFormProps {
|
||||||
initialData?: {
|
initialData?: {
|
||||||
id: string
|
id?: string
|
||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
email: string
|
email: string
|
||||||
role: "admin" | "user"
|
role: "admin" | "user"
|
||||||
|
phone?: string
|
||||||
}
|
}
|
||||||
|
mode?: "admin" | "profile"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserForm({ initialData }: UserFormProps) {
|
export function UserForm({ initialData, mode = "admin" }: UserFormProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const returnTo = searchParams.get("returnTo")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
const form = useForm<UserFormValues>({
|
const form = useForm<UserFormValues>({
|
||||||
@@ -63,13 +88,17 @@ export function UserForm({ initialData }: UserFormProps) {
|
|||||||
lastName: initialData.lastName,
|
lastName: initialData.lastName,
|
||||||
email: initialData.email,
|
email: initialData.email,
|
||||||
password: "", // Empty password means no change
|
password: "", // Empty password means no change
|
||||||
|
confirmPassword: "",
|
||||||
role: initialData.role,
|
role: initialData.role,
|
||||||
|
phone: initialData.phone,
|
||||||
} : {
|
} : {
|
||||||
firstName: "",
|
firstName: "",
|
||||||
lastName: "",
|
lastName: "",
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
|
confirmPassword: "",
|
||||||
role: "user",
|
role: "user",
|
||||||
|
phone: "",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -77,17 +106,28 @@ export function UserForm({ initialData }: UserFormProps) {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
let result;
|
let result;
|
||||||
if (initialData) {
|
|
||||||
// Update
|
if (mode === "profile") {
|
||||||
result = await updateUser(initialData.id, data)
|
// Profile update mode (self-service)
|
||||||
|
result = await updateProfile({
|
||||||
|
firstName: data.firstName,
|
||||||
|
lastName: data.lastName,
|
||||||
|
phone: data.phone
|
||||||
|
})
|
||||||
|
} else if (initialData?.id) {
|
||||||
|
// Admin update mode
|
||||||
|
result = await updateUser(initialData.id, {
|
||||||
|
firstName: data.firstName,
|
||||||
|
lastName: data.lastName,
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
role: data.role,
|
||||||
|
phone: data.phone
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
// Create
|
// Admin create mode
|
||||||
if (!data.password || data.password.length < 6) {
|
// Password requirement is now handled by Zod Schema
|
||||||
toast.error("Yeni kullanıcı için şifre gereklidir (min 6 karakter).")
|
result = await createUser(data.firstName, data.lastName, data.email, data.password!, data.role, data.phone)
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
result = await createUser(data.firstName, data.lastName, data.email, data.password, data.role)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
@@ -95,10 +135,20 @@ export function UserForm({ initialData }: UserFormProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(initialData ? "Kullanıcı güncellendi." : "Kullanıcı oluşturuldu.")
|
toast.success(
|
||||||
router.push("/dashboard/users")
|
mode === "profile"
|
||||||
|
? "Profil bilgileriniz güncellendi."
|
||||||
|
: initialData ? "Kullanıcı güncellendi." : "Kullanıcı oluşturuldu."
|
||||||
|
)
|
||||||
|
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} catch (error) {
|
router.refresh()
|
||||||
|
if (returnTo) {
|
||||||
|
router.push(returnTo)
|
||||||
|
} else if (mode === "admin") {
|
||||||
|
router.push("/dashboard/users")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
toast.error("Bir sorun oluştu.")
|
toast.error("Bir sorun oluştu.")
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -141,18 +191,40 @@ export function UserForm({ initialData }: UserFormProps) {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="email"
|
name="phone"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>E-posta</FormLabel>
|
<FormLabel>Telefon</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="ahmet@parakasa.com" {...field} />
|
<PhoneInput placeholder="555 123 4567" defaultCountry="TR" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>E-posta</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="ahmet@parakasa.com"
|
||||||
|
{...field}
|
||||||
|
disabled={mode === "profile"}
|
||||||
|
className={mode === "profile" ? "bg-muted" : ""}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
{mode === "profile" && <p className="text-[0.8rem] text-muted-foreground">E-posta adresi değiştirilemez.</p>}
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{mode === "admin" && (
|
||||||
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="password"
|
name="password"
|
||||||
@@ -167,6 +239,20 @@ export function UserForm({ initialData }: UserFormProps) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="confirmPassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Şifre Tekrar</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="******" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="role"
|
name="role"
|
||||||
@@ -188,10 +274,19 @@ export function UserForm({ initialData }: UserFormProps) {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "profile" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<FormLabel>Rol</FormLabel>
|
||||||
|
<Input value={initialData?.role === 'admin' ? 'Yönetici' : 'Kullanıcı'} disabled className="bg-muted" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button type="submit" disabled={loading} className="w-full">
|
<Button type="submit" disabled={loading} className="w-full">
|
||||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
Kullanıcı Oluştur
|
{mode === "profile" ? "Değişiklikleri Kaydet" : (initialData ? "Kaydet" : "Kullanıcı Oluştur")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -14,61 +14,85 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuShortcut,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { supabase } from "@/lib/supabase"
|
import { createBrowserClient } from "@supabase/ssr"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
export function UserNav() {
|
|
||||||
|
interface UserNavProps {
|
||||||
|
user: {
|
||||||
|
email?: string | null
|
||||||
|
} | null
|
||||||
|
profile: {
|
||||||
|
full_name?: string | null
|
||||||
|
role?: string | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserNav({ user, profile }: UserNavProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const supabase = createBrowserClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
|
)
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
await supabase.auth.signOut()
|
await supabase.auth.signOut()
|
||||||
router.push("/login")
|
router.push("/")
|
||||||
router.refresh()
|
router.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getInitials = (name: string) => {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.substring(0, 2)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
<Button variant="ghost" className="relative h-9 w-9 rounded-full ring-2 ring-primary/10 ring-offset-2 hover:ring-primary/20 transition-all">
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-9 w-9">
|
||||||
<AvatarImage src="/avatars/01.png" alt="@parakasa" />
|
<AvatarImage src="" alt={profile?.full_name || "@parakasa"} />
|
||||||
<AvatarFallback>PK</AvatarFallback>
|
<AvatarFallback className="bg-gradient-to-br from-blue-600 to-indigo-600 text-white font-bold text-xs">
|
||||||
|
{profile?.full_name
|
||||||
|
? getInitials(profile.full_name)
|
||||||
|
: user?.email
|
||||||
|
? user.email.substring(0, 2).toUpperCase()
|
||||||
|
: 'PK'}
|
||||||
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
<DropdownMenuContent className="w-56" align="end">
|
||||||
<DropdownMenuLabel className="font-normal">
|
<DropdownMenuLabel className="font-normal">
|
||||||
<div className="flex flex-col space-y-1">
|
<div className="flex flex-col space-y-1">
|
||||||
<p className="text-sm font-medium leading-none">Admin</p>
|
<p className="text-sm font-medium leading-none">{profile?.full_name || 'Kullanıcı'}</p>
|
||||||
<p className="text-xs leading-none text-muted-foreground">
|
<p className="text-xs leading-none text-muted-foreground">
|
||||||
admin@parakasa.com
|
{user?.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
<Link href="/dashboard/profile">
|
<Link href="/dashboard/profile">
|
||||||
<DropdownMenuItem className="cursor-pointer">
|
Profil Bilgileri
|
||||||
Profil
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/dashboard/users">
|
|
||||||
<DropdownMenuItem className="cursor-pointer">
|
|
||||||
Kullanıcılar
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild className="cursor-pointer">
|
||||||
|
<Link href="/dashboard/profile/password">
|
||||||
|
Şifre Değiştir
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/dashboard/settings">
|
|
||||||
<DropdownMenuItem className="cursor-pointer">
|
|
||||||
Ayarlar
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</Link>
|
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleSignOut}>
|
<DropdownMenuItem onClick={handleSignOut} className="cursor-pointer text-red-600 focus:text-red-600">
|
||||||
Çıkış Yap
|
Çıkış
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
67
components/dashboard/users-table.tsx
Normal file
67
components/dashboard/users-table.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import Link from "next/link"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
id: string
|
||||||
|
full_name: string
|
||||||
|
role: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsersTableProps {
|
||||||
|
users: Profile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsersTable({ users }: UsersTableProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between space-y-2">
|
||||||
|
<h3 className="text-lg font-medium">Kullanıcı Listesi</h3>
|
||||||
|
<Link href="/dashboard/users/new?returnTo=/dashboard/settings">
|
||||||
|
<Button size="sm">Yeni Kullanıcı</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Ad Soyad</TableHead>
|
||||||
|
<TableHead>Rol</TableHead>
|
||||||
|
<TableHead>Kayıt Tarihi</TableHead>
|
||||||
|
<TableHead className="text-right">İşlemler</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users?.map((profile) => (
|
||||||
|
<TableRow key={profile.id}>
|
||||||
|
<TableCell className="font-medium">{profile.full_name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={profile.role === 'admin' ? 'default' : 'secondary'}>
|
||||||
|
{profile.role === 'admin' ? 'Yönetici' : 'Kullanıcı'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{new Date(profile.created_at).toLocaleDateString('tr-TR')}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Link href={`/dashboard/users/${profile.id}`}>
|
||||||
|
<Button variant="ghost" size="sm">Düzenle</Button>
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { getSiteContents } from "@/lib/data"
|
||||||
|
import { Instagram, Youtube } from "lucide-react"
|
||||||
|
import { FaTiktok } from "react-icons/fa"
|
||||||
|
|
||||||
|
export async function Footer() {
|
||||||
|
const siteSettings = await getSiteContents()
|
||||||
|
|
||||||
export function Footer() {
|
|
||||||
return (
|
return (
|
||||||
<footer className="w-full border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-12 md:py-24 lg:py-32">
|
<footer className="w-full border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-12 md:py-24 lg:py-32">
|
||||||
<div className="container grid gap-8 px-4 md:px-6 lg:grid-cols-4">
|
<div className="container grid gap-8 px-4 md:px-6 lg:grid-cols-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Link href="/" className="flex items-center gap-2">
|
<Link href="/" className="flex items-center gap-2">
|
||||||
<span className="text-xl font-bold tracking-tighter">PARAKASA</span>
|
<span className="text-xl font-bold tracking-tighter">{siteSettings.site_title || "PARAKASA"}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Yüksek güvenlikli çelik kasa ve para sayma sistemleri için güvenilir çözüm ortağınız.
|
{siteSettings.site_description || "Yüksek güvenlikli çelik kasa ve para sayma sistemleri için güvenilir çözüm ortağınız."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -44,19 +49,36 @@ export function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h3 className="font-semibold">Bize Ulaşın</h3>
|
<h3 className="font-semibold">Bize Ulaşın</h3>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||||
İstanbul, Türkiye
|
{siteSettings.contact_address || "İstanbul, Türkiye"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
+90 212 000 00 00
|
{siteSettings.contact_phone || "+90 212 000 00 00"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{siteSettings.contact_email}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-4 mt-4">
|
<div className="flex gap-4 mt-4">
|
||||||
{/* Social Media Icons would go here */}
|
{siteSettings.social_instagram && (
|
||||||
|
<Link href={siteSettings.social_instagram} target="_blank" className="text-muted-foreground hover:text-foreground">
|
||||||
|
<Instagram className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{siteSettings.social_youtube && (
|
||||||
|
<Link href={siteSettings.social_youtube} target="_blank" className="text-muted-foreground hover:text-foreground">
|
||||||
|
<Youtube className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{siteSettings.social_tiktok && (
|
||||||
|
<Link href={siteSettings.social_tiktok} target="_blank" className="text-muted-foreground hover:text-foreground">
|
||||||
|
<FaTiktok className="h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="container px-4 md:px-6 mt-8 pt-8 border-t text-center text-sm text-muted-foreground">
|
<div className="container px-4 md:px-6 mt-8 pt-8 border-t text-center text-sm text-muted-foreground">
|
||||||
© 2026 ParaKasa. Tüm hakları saklıdır.
|
© {new Date().getFullYear()} {siteSettings.site_title || "ParaKasa"}. Tüm hakları saklıdır.
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,19 +4,33 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { createClient } from "@/lib/supabase-server"
|
import { createClient } from "@/lib/supabase-server"
|
||||||
|
import { getSiteContents } from "@/lib/data"
|
||||||
|
import Image from "next/image"
|
||||||
|
|
||||||
export async function Navbar() {
|
export async function Navbar() {
|
||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
const { data: { user } } = await supabase.auth.getUser()
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
const siteSettings = await getSiteContents()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div className="container flex h-16 items-center">
|
<div className="container flex h-16 items-center">
|
||||||
<div className="mr-8 hidden md:flex">
|
<div className="mr-8 hidden md:flex">
|
||||||
<Link href="/" className="mr-6 flex items-center space-x-2">
|
<Link href="/" className="mr-6 flex items-center space-x-2">
|
||||||
|
{siteSettings.site_logo ? (
|
||||||
|
<div className="relative h-10 w-32">
|
||||||
|
<Image
|
||||||
|
src={siteSettings.site_logo}
|
||||||
|
alt={siteSettings.site_title || "ParaKasa"}
|
||||||
|
fill
|
||||||
|
className="object-contain object-left"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<span className="text-xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-slate-900 to-slate-500 dark:from-slate-100 dark:to-slate-400">
|
<span className="text-xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-slate-900 to-slate-500 dark:from-slate-100 dark:to-slate-400">
|
||||||
PARAKASA
|
{siteSettings.site_title || "PARAKASA"}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center space-x-6 text-sm font-medium">
|
<nav className="flex items-center space-x-6 text-sm font-medium">
|
||||||
<Link
|
<Link
|
||||||
@@ -52,8 +66,19 @@ export async function Navbar() {
|
|||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="left" className="pr-0">
|
<SheetContent side="left" className="pr-0">
|
||||||
<Link href="/" className="flex items-center">
|
<Link href="/" className="flex items-center mb-6">
|
||||||
<span className="font-bold">PARAKASA</span>
|
{siteSettings.site_logo ? (
|
||||||
|
<div className="relative h-10 w-32">
|
||||||
|
<Image
|
||||||
|
src={siteSettings.site_logo}
|
||||||
|
alt={siteSettings.site_title || "ParaKasa"}
|
||||||
|
fill
|
||||||
|
className="object-contain object-left"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="font-bold text-xl">{siteSettings.site_title || "PARAKASA"}</span>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex flex-col gap-4 mt-8">
|
<div className="flex flex-col gap-4 mt-8">
|
||||||
<Link href="/products">Ürünler</Link>
|
<Link href="/products">Ürünler</Link>
|
||||||
@@ -63,8 +88,26 @@ export async function Navbar() {
|
|||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
|
{/* Mobile Logo (Visible only on mobile) */}
|
||||||
|
<Link href="/" className="mr-6 flex items-center space-x-2 md:hidden">
|
||||||
|
{siteSettings.site_logo ? (
|
||||||
|
<div className="relative h-8 w-24">
|
||||||
|
<Image
|
||||||
|
src={siteSettings.site_logo}
|
||||||
|
alt={siteSettings.site_title || "ParaKasa"}
|
||||||
|
fill
|
||||||
|
className="object-contain object-left"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-lg font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-slate-900 to-slate-500 dark:from-slate-100 dark:to-slate-400">
|
||||||
|
{siteSettings.site_title || "PARAKASA"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
||||||
<div className="w-full flex-1 md:w-auto md:flex-none">
|
<div className="w-full flex-1 md:w-auto md:flex-none hidden md:block">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
94
components/product/product-gallery.tsx
Normal file
94
components/product/product-gallery.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import Image from "next/image"
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface ProductGalleryProps {
|
||||||
|
images: string[]
|
||||||
|
productName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductGallery({ images, productName }: ProductGalleryProps) {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
|
|
||||||
|
if (!images || images.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="relative aspect-square overflow-hidden rounded-xl border bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400">
|
||||||
|
Görsel Yok
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextImage = () => {
|
||||||
|
setSelectedIndex((prev) => (prev + 1) % images.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevImage = () => {
|
||||||
|
setSelectedIndex((prev) => (prev - 1 + images.length) % images.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Main Image */}
|
||||||
|
<div className="group relative aspect-square overflow-hidden rounded-xl border bg-slate-100 dark:bg-slate-800">
|
||||||
|
<Image
|
||||||
|
src={images[selectedIndex]}
|
||||||
|
alt={`${productName} - Görsel ${selectedIndex + 1}`}
|
||||||
|
fill
|
||||||
|
className="object-cover transition-all duration-300"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Navigation Buttons (Only if multiple images) */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute left-2 top-1/2 -translate-y-1/2 h-8 w-8 rounded-full bg-white/80 hover:bg-white text-slate-800 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={prevImage}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 h-8 w-8 rounded-full bg-white/80 hover:bg-white text-slate-800 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={nextImage}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnails */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
{images.map((img, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
onClick={() => setSelectedIndex(idx)}
|
||||||
|
className={cn(
|
||||||
|
"relative aspect-square overflow-hidden rounded-lg border bg-slate-100 dark:bg-slate-800 transition-all ring-offset-2",
|
||||||
|
selectedIndex === idx
|
||||||
|
? "ring-2 ring-primary"
|
||||||
|
: "opacity-70 hover:opacity-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={img}
|
||||||
|
alt={`${productName} thumbnail ${idx + 1}`}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -36,27 +36,21 @@ const buttonVariants = cva(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function Button({
|
const Button = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button"> &
|
||||||
className,
|
|
||||||
variant = "default",
|
|
||||||
size = "default",
|
|
||||||
asChild = false,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"button"> &
|
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean
|
asChild?: boolean
|
||||||
}) {
|
}>(({ className, variant = "default", size = "default", asChild = false, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : "button"
|
const Comp = asChild ? Slot : "button"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
data-variant={variant}
|
|
||||||
data-size={size}
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants }
|
||||||
|
|||||||
32
components/ui/checkbox.tsx
Normal file
32
components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Checkbox({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon className="size-3.5" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
141
components/ui/image-upload.tsx
Normal file
141
components/ui/image-upload.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useRef } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Loader2, Upload, X } from "lucide-react"
|
||||||
|
import imageCompression from "browser-image-compression"
|
||||||
|
import { createClient } from "@/lib/supabase-browser"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import Image from "next/image"
|
||||||
|
|
||||||
|
interface ImageUploadProps {
|
||||||
|
value?: string
|
||||||
|
onChange: (url: string) => void
|
||||||
|
onRemove: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageUpload({ value, onChange, onRemove, disabled }: ImageUploadProps) {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const supabase = createClient()
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
// 1. Client-side Compression
|
||||||
|
const options = {
|
||||||
|
maxSizeMB: 1, // Max 1MB
|
||||||
|
maxWidthOrHeight: 1920, // Max 1920px width/height
|
||||||
|
useWebWorker: true,
|
||||||
|
fileType: "image/webp" // Convert to WebP if possible
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: browser-image-compression might return Blob instead of File
|
||||||
|
const compressedFile = await imageCompression(file, options)
|
||||||
|
|
||||||
|
// Create a unique file name
|
||||||
|
// folder structure: sliders/[timestamp]-[random].webp
|
||||||
|
const fileExt = "webp" // we are forcing conversion to webp usually, or use compressedFile.type
|
||||||
|
const fileName = `${Date.now()}-${Math.floor(Math.random() * 1000)}.${fileExt}`
|
||||||
|
const filePath = `uploads/${fileName}`
|
||||||
|
|
||||||
|
// 2. Upload to Supabase
|
||||||
|
const { error: uploadError } = await supabase.storage
|
||||||
|
.from("images")
|
||||||
|
.upload(filePath, compressedFile)
|
||||||
|
|
||||||
|
if (uploadError) {
|
||||||
|
throw uploadError
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get Public URL
|
||||||
|
const { data: { publicUrl } } = supabase.storage
|
||||||
|
.from("images")
|
||||||
|
.getPublicUrl(filePath)
|
||||||
|
|
||||||
|
onChange(publicUrl)
|
||||||
|
toast.success("Resim yüklendi ve optimize edildi.")
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Upload error:", error)
|
||||||
|
toast.error("Resim yüklenirken hata oluştu: " + (error as Error).message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 w-full">
|
||||||
|
<Label>Görsel</Label>
|
||||||
|
|
||||||
|
{value ? (
|
||||||
|
<div className="relative aspect-video w-full max-w-md rounded-lg overflow-hidden border bg-slate-100 dark:bg-slate-800">
|
||||||
|
<Image
|
||||||
|
src={value}
|
||||||
|
alt="Upload preview"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-2 right-2 h-8 w-8"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="
|
||||||
|
border-2 border-dashed border-slate-300 dark:border-slate-700
|
||||||
|
rounded-lg p-12
|
||||||
|
flex flex-col items-center justify-center
|
||||||
|
text-slate-500 dark:text-slate-400
|
||||||
|
hover:bg-slate-50 dark:hover:bg-slate-900/50
|
||||||
|
transition cursor-pointer
|
||||||
|
w-full max-w-md
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={loading || disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Loader2 className="h-10 w-10 animate-spin text-primary" />
|
||||||
|
<p className="text-sm font-medium">Optimize ediliyor ve yükleniyor...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<div className="p-4 bg-slate-100 dark:bg-slate-800 rounded-full">
|
||||||
|
<Upload className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">Resim Yükle</p>
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
Tıklayın veya sürükleyin.<br />
|
||||||
|
(Otomatik sıkıştırma: Max 1MB, WebP)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
components/ui/phone-input.tsx
Normal file
25
components/ui/phone-input.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import PhoneInput from "react-phone-number-input"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface PhoneInputProps extends React.ComponentProps<typeof PhoneInput> {
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const PhoneInputComponent = React.forwardRef<any, PhoneInputProps>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<PhoneInput
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex", className)} // Wrapper class
|
||||||
|
numberInputProps={{
|
||||||
|
className: "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
}}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
{...(props as any)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
PhoneInputComponent.displayName = "PhoneInput"
|
||||||
|
|
||||||
|
export { PhoneInputComponent as PhoneInput }
|
||||||
58
components/ui/scroll-area.tsx
Normal file
58
components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
15
components/ui/skeleton.tsx
Normal file
15
components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
55
components/ui/tabs.tsx
Normal file
55
components/ui/tabs.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
@@ -13,7 +13,7 @@ export async function submitContactForm(data: ContactFormValues) {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
// In a real app, you would use Resend or Nodemailer here
|
// In a real app, you would use Resend or Nodemailer here
|
||||||
console.log("Contact Form Submitted:", result.data)
|
|
||||||
|
|
||||||
return { success: true, message: "Mesajınız başarıyla gönderildi." }
|
return { success: true, message: "Mesajınız başarıyla gönderildi." }
|
||||||
}
|
}
|
||||||
|
|||||||
88
lib/customers/actions.ts
Normal file
88
lib/customers/actions.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { createClient } from "@/lib/supabase-server"
|
||||||
|
import { Customer, CustomerInsert, CustomerUpdate } from "@/types/customer"
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
|
||||||
|
// Get all customers
|
||||||
|
export async function getCustomers() {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('customers')
|
||||||
|
.select('*')
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error fetching customers:', error)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: data as Customer[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get customer by ID
|
||||||
|
export async function getCustomerById(id: number) {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('customers')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: data as Customer }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new customer
|
||||||
|
export async function addCustomer(customer: CustomerInsert) {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('customers')
|
||||||
|
.insert(customer)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/dashboard/customers')
|
||||||
|
return { success: true, data: data as Customer }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing customer
|
||||||
|
export async function updateCustomer(id: number, customer: CustomerUpdate) {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('customers')
|
||||||
|
.update({ ...customer, updated_at: new Date().toISOString() })
|
||||||
|
.eq('id', id)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/dashboard/customers')
|
||||||
|
return { success: true, data: data as Customer }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete customer
|
||||||
|
export async function deleteCustomer(id: number) {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('customers')
|
||||||
|
.delete()
|
||||||
|
.eq('id', id)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/dashboard/customers')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
31
lib/data.ts
Normal file
31
lib/data.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { cache } from 'react'
|
||||||
|
import { createClient } from '@/lib/supabase-server'
|
||||||
|
|
||||||
|
export const getProfile = cache(async (userId: string) => {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', userId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
return data
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getSiteContents = cache(async () => {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('site_contents')
|
||||||
|
.select('*')
|
||||||
|
|
||||||
|
// Convert to a simpler key-value map for easier usage in components
|
||||||
|
const contentMap: Record<string, string> = {}
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
data.forEach((item: { key: string; value: string }) => {
|
||||||
|
contentMap[item.key] = item.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return contentMap
|
||||||
|
})
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { createClient } from "@/lib/supabase-server"
|
|
||||||
import { cache } from "react"
|
|
||||||
|
|
||||||
export const getSiteSettings = cache(async () => {
|
|
||||||
const supabase = createClient()
|
|
||||||
const { data } = await supabase.from('site_settings').select('*').single()
|
|
||||||
return data
|
|
||||||
})
|
|
||||||
203
lib/sms/actions.ts
Normal file
203
lib/sms/actions.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { createClient } from "@/lib/supabase-server"
|
||||||
|
import { createClient as createSupabaseClient } from "@supabase/supabase-js"
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
import { NetGsmService } from "./netgsm"
|
||||||
|
|
||||||
|
// Admin client for privileged operations (accessing sms_settings)
|
||||||
|
const supabaseAdmin = createSupabaseClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
||||||
|
{
|
||||||
|
auth: {
|
||||||
|
autoRefreshToken: false,
|
||||||
|
persistSession: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async function assertAdmin() {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
if (!user) throw new Error("Oturum açmanız gerekiyor.")
|
||||||
|
|
||||||
|
const { data: profile } = await supabase.from('profiles').select('role').eq('id', user.id).single()
|
||||||
|
if (profile?.role !== 'admin') throw new Error("Yetkisiz işlem.")
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSmsSettings() {
|
||||||
|
try {
|
||||||
|
await assertAdmin()
|
||||||
|
|
||||||
|
const { data, error } = await supabaseAdmin
|
||||||
|
.from('sms_settings')
|
||||||
|
.select('*')
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error && error.code !== 'PGRST116') { // PGRST116 is 'not found', which is fine initially
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data }
|
||||||
|
} catch (error) {
|
||||||
|
return { error: (error as Error).message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSmsSettings(data: {
|
||||||
|
username: string
|
||||||
|
password?: string // Optional if not changing
|
||||||
|
header: string
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
await assertAdmin()
|
||||||
|
|
||||||
|
// Check if exists
|
||||||
|
const { data: existing } = await supabaseAdmin.from('sms_settings').select('id').single()
|
||||||
|
|
||||||
|
const updates: {
|
||||||
|
username: string
|
||||||
|
header: string
|
||||||
|
updated_at: string
|
||||||
|
password?: string
|
||||||
|
} = {
|
||||||
|
username: data.username,
|
||||||
|
header: data.header,
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update password if provided
|
||||||
|
if (data.password && data.password.trim() !== '') {
|
||||||
|
updates.password = data.password
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const { error } = await supabaseAdmin
|
||||||
|
.from('sms_settings')
|
||||||
|
.update(updates)
|
||||||
|
.eq('id', existing.id)
|
||||||
|
if (error) throw error
|
||||||
|
} else {
|
||||||
|
// First time setup, password is mandatory if not exists, but we can't easily check 'locally'
|
||||||
|
// We assume if new, password must be in updates.
|
||||||
|
if (!data.password) throw new Error("Yeni kurulum için şifre gereklidir.")
|
||||||
|
|
||||||
|
const { error } = await supabaseAdmin
|
||||||
|
.from('sms_settings')
|
||||||
|
.insert({ ...updates, password: data.password })
|
||||||
|
if (error) throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/settings")
|
||||||
|
return { success: true }
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return { error: (error as Error).message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendTestSms(phone: string) {
|
||||||
|
try {
|
||||||
|
await assertAdmin()
|
||||||
|
|
||||||
|
// Fetch credentials
|
||||||
|
const { data: settings } = await supabaseAdmin.from('sms_settings').select('*').single()
|
||||||
|
if (!settings) throw new Error("SMS ayarları yapılmamış.")
|
||||||
|
|
||||||
|
const mobileService = new NetGsmService({
|
||||||
|
username: settings.username,
|
||||||
|
password: settings.password,
|
||||||
|
header: settings.header,
|
||||||
|
apiUrl: settings.api_url
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await mobileService.sendSms(phone, "ParaKasa Test SMS: Entegrasyon basarili.")
|
||||||
|
|
||||||
|
// Log the result
|
||||||
|
await supabaseAdmin.from('sms_logs').insert({
|
||||||
|
phone,
|
||||||
|
message: "ParaKasa Test SMS: Entegrasyon basarili.",
|
||||||
|
status: result.success ? 'success' : 'error',
|
||||||
|
response_code: result.code || result.error
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || "SMS gönderilemedi.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, jobId: result.jobId }
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return { error: (error as Error).message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendBulkSms(phones: string[], message: string) {
|
||||||
|
try {
|
||||||
|
await assertAdmin()
|
||||||
|
|
||||||
|
// Fetch credentials
|
||||||
|
const { data: settings } = await supabaseAdmin.from('sms_settings').select('*').single()
|
||||||
|
if (!settings) throw new Error("SMS ayarları yapılmamış.")
|
||||||
|
|
||||||
|
const mobileService = new NetGsmService({
|
||||||
|
username: settings.username,
|
||||||
|
password: settings.password,
|
||||||
|
header: settings.header,
|
||||||
|
apiUrl: settings.api_url
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove duplicates and empty
|
||||||
|
const uniquePhones = Array.from(new Set(phones.filter(p => p && p.trim() !== '')))
|
||||||
|
|
||||||
|
const results = []
|
||||||
|
|
||||||
|
for (const phone of uniquePhones) {
|
||||||
|
const result = await mobileService.sendSms(phone, message)
|
||||||
|
|
||||||
|
// Log result
|
||||||
|
await supabaseAdmin.from('sms_logs').insert({
|
||||||
|
phone,
|
||||||
|
message,
|
||||||
|
status: result.success ? 'success' : 'error',
|
||||||
|
response_code: result.code || result.error
|
||||||
|
})
|
||||||
|
|
||||||
|
results.push({ phone, ...result })
|
||||||
|
}
|
||||||
|
|
||||||
|
const successCount = results.filter(r => r.success).length
|
||||||
|
const total = uniquePhones.length
|
||||||
|
|
||||||
|
revalidatePath("/dashboard/sms")
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `${total} kişiden ${successCount} kişiye başarıyla gönderildi.`,
|
||||||
|
details: results
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return { error: (error as Error).message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSmsLogs(limit: number = 50) {
|
||||||
|
try {
|
||||||
|
await assertAdmin()
|
||||||
|
|
||||||
|
const { data, error } = await supabaseAdmin
|
||||||
|
.from('sms_logs')
|
||||||
|
.select('*')
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
|
||||||
|
return { success: true, data }
|
||||||
|
} catch (error) {
|
||||||
|
return { error: (error as Error).message }
|
||||||
|
}
|
||||||
|
}
|
||||||
88
lib/sms/netgsm.ts
Normal file
88
lib/sms/netgsm.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
export interface NetGsmConfig {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
header?: string;
|
||||||
|
apiUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmsResult {
|
||||||
|
success: boolean;
|
||||||
|
jobId?: string;
|
||||||
|
error?: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NetGsmService {
|
||||||
|
private config: NetGsmConfig;
|
||||||
|
|
||||||
|
constructor(config: NetGsmConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send SMS using NetGSM GET API
|
||||||
|
* Refer: https://www.netgsm.com.tr/dokuman/#http-get-servisi
|
||||||
|
*/
|
||||||
|
async sendSms(phone: string, message: string): Promise<SmsResult> {
|
||||||
|
if (!this.config.username || !this.config.password || !this.config.header) {
|
||||||
|
return { success: false, error: "NetGSM konfigürasyonu eksik." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean phone number (remove spaces, parentheses, etc)
|
||||||
|
// NetGSM expects 905xxxxxxxxx or just 5xxxxxxxxx, we'll ensure format
|
||||||
|
let cleanPhone = phone.replace(/\D/g, '');
|
||||||
|
if (cleanPhone.startsWith('90')) {
|
||||||
|
cleanPhone = cleanPhone.substring(0); // keep it
|
||||||
|
} else if (cleanPhone.startsWith('0')) {
|
||||||
|
cleanPhone = '9' + cleanPhone;
|
||||||
|
} else if (cleanPhone.length === 10) {
|
||||||
|
cleanPhone = '90' + cleanPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Encode parameters
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
usercode: this.config.username,
|
||||||
|
password: this.config.password,
|
||||||
|
gsmno: cleanPhone,
|
||||||
|
message: message,
|
||||||
|
msgheader: this.config.header,
|
||||||
|
dil: 'TR' // Turkish characters support
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `${this.config.apiUrl || 'https://api.netgsm.com.tr/sms/send/get'}?${params.toString()}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
const textResponse = await response.text();
|
||||||
|
|
||||||
|
// NetGSM returns a code (e.g. 00 123456789) or error code (e.g. 20)
|
||||||
|
// Codes starting with 00, 01, 02 indicate success
|
||||||
|
const code = textResponse.split(' ')[0];
|
||||||
|
|
||||||
|
if (['00', '01', '02'].includes(code)) {
|
||||||
|
return { success: true, jobId: textResponse.split(' ')[1] || code, code };
|
||||||
|
} else {
|
||||||
|
const errorMap: Record<string, string> = {
|
||||||
|
'20': 'Mesaj metni ya da karakter sınırını (1.000) aştı veya mesaj boş.',
|
||||||
|
'30': 'Geçersiz kullanıcı adı , şifre veya kullanıcınızın API erişim izni yok.',
|
||||||
|
'40': 'Gönderici adı (Başlık) sistemde tanımlı değil.',
|
||||||
|
'70': 'Hatalı sorgu.',
|
||||||
|
'50': 'Kendi numaranıza veya Rehberden SMS gönderiyorsanız; Abone kendi numarasını veya rehberindeki bir numarayı gönderici kimliği (MsgHeader) olarak kullanamaz.',
|
||||||
|
'51': 'Aboneliğinizin süresi dolmuş.',
|
||||||
|
'52': 'Aboneliğiniz bulunmamaktadır.',
|
||||||
|
'60': 'Bakiyeniz yetersiz.',
|
||||||
|
'71': 'Gönderim yapmak istediğiniz gsm numarası/numaraları hatalı.'
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
code,
|
||||||
|
error: errorMap[code] || `Bilinmeyen hata kodu: ${code} - Yanıt: ${textResponse}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
lib/sms/templates.ts
Normal file
57
lib/sms/templates.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { createClient } from "@/lib/supabase-server"
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
|
|
||||||
|
export interface SmsTemplate {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTemplates() {
|
||||||
|
try {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('sms_templates')
|
||||||
|
.select('*')
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return { success: true, data: data as SmsTemplate[] }
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTemplate(title: string, message: string) {
|
||||||
|
try {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('sms_templates')
|
||||||
|
.insert({ title, message })
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
revalidatePath('/dashboard/sms')
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTemplate(id: string) {
|
||||||
|
try {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('sms_templates')
|
||||||
|
.delete()
|
||||||
|
.eq('id', id)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
revalidatePath('/dashboard/sms')
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message }
|
||||||
|
}
|
||||||
|
}
|
||||||
124
lib/sms/verification-actions.ts
Normal file
124
lib/sms/verification-actions.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
import { createClient } from "@/lib/supabase-server"
|
||||||
|
import { createClient as createSupabaseClient } from "@supabase/supabase-js"
|
||||||
|
|
||||||
|
import { cookies } from "next/headers"
|
||||||
|
import { NetGsmService } from "./netgsm"
|
||||||
|
// We will reuse sendTestSms logic or create a specific one. sendTestSms uses Netgsm Service.
|
||||||
|
// Better to export a generic 'sendSms' from lib/sms/actions.ts or just invoke the service directly.
|
||||||
|
// lib/sms/actions.ts has `sendBulkSms` and `sendTestSms`. I should probably expose a generic `sendSms` there.
|
||||||
|
|
||||||
|
// Admin client for Auth Codes table access
|
||||||
|
const supabaseAdmin = createSupabaseClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
||||||
|
{ auth: { autoRefreshToken: false, persistSession: false } }
|
||||||
|
)
|
||||||
|
|
||||||
|
export async function sendVerificationCode() {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
if (!user) return { error: "Kullanıcı bulunamadı." }
|
||||||
|
|
||||||
|
// 1. Get user phone
|
||||||
|
const { data: profile } = await supabaseAdmin
|
||||||
|
.from('profiles')
|
||||||
|
.select('phone')
|
||||||
|
.eq('id', user.id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (!profile?.phone) {
|
||||||
|
return { error: "Profilinizde telefon numarası tanımlı değil. Lütfen yöneticinizle iletişime geçin." }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Generate Code
|
||||||
|
const code = Math.floor(100000 + Math.random() * 900000).toString() // 6 digit
|
||||||
|
const expiresAt = new Date(Date.now() + 5 * 60 * 1000) // 5 mins
|
||||||
|
|
||||||
|
// 3. Store in DB
|
||||||
|
// First, delete old codes for this email/user
|
||||||
|
await supabaseAdmin.from('auth_codes').delete().eq('email', user.email!)
|
||||||
|
|
||||||
|
const { error: dbError } = await supabaseAdmin.from('auth_codes').insert({
|
||||||
|
email: user.email!,
|
||||||
|
code,
|
||||||
|
expires_at: expiresAt.toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (dbError) {
|
||||||
|
console.error("Auth code db error:", dbError)
|
||||||
|
return { error: "Doğrulama kodu oluşturulamadı." }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Send SMS
|
||||||
|
// We import the logic from Netgsm service wrapper
|
||||||
|
// Since we don't have a direct 'sendSms' export in existing actions that accepts phone/message directly without admin assertion (which we have here via admin client, but the helper function `sendTestSms` does its own checks).
|
||||||
|
// I will use a direct call to the generic `NetgsmService` logic if I can, or modify `lib/sms/actions.ts` to export it.
|
||||||
|
// To avoid modifying too many files, I'll instantiate NetgsmService here if I can import it, or just use `sendBulkSms` with one number?
|
||||||
|
// `sendBulkSms` asserts admin user. But here the calling user IS logged in (but might not be admin?).
|
||||||
|
// Actually, `sendVerificationCode` is called by the logging-in user (who might be just 'user' role).
|
||||||
|
// `lib/sms/actions.ts` -> `assertAdmin()` checks if current user is admin.
|
||||||
|
// So if a normal user logs in, `sendBulkSms` will fail.
|
||||||
|
// WE NEED A SYSTEM LEVEL SEND FUNCTION.
|
||||||
|
|
||||||
|
// I will read credentials directly using Admin Client here.
|
||||||
|
const { data: settings } = await supabaseAdmin.from('sms_settings').select('*').single()
|
||||||
|
if (!settings) return { error: "SMS servisi yapılandırılmamış." }
|
||||||
|
|
||||||
|
// Import the class dynamically or duplicate usage?
|
||||||
|
// The class is in `./netgsm.ts` (based on actions.ts imports).
|
||||||
|
// Let's import { NetGsmService } from "./netgsm"
|
||||||
|
// NetGsmService imported at top
|
||||||
|
|
||||||
|
const mobileService = new NetGsmService({
|
||||||
|
username: settings.username,
|
||||||
|
password: settings.password,
|
||||||
|
header: settings.header,
|
||||||
|
apiUrl: settings.api_url
|
||||||
|
})
|
||||||
|
|
||||||
|
const smsResult = await mobileService.sendSms(profile.phone, `Giris Dogrulama Kodunuz: ${code}`)
|
||||||
|
|
||||||
|
if (!smsResult.success) {
|
||||||
|
console.error("SMS Send Error:", smsResult)
|
||||||
|
return { error: "SMS gönderilemedi. Lütfen daha sonra tekrar deneyin." }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, phone: profile.phone.slice(-4) } // Return last 4 digits
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyCode(code: string) {
|
||||||
|
const supabase = createClient()
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
if (!user) return { error: "Oturum bulunamadı." }
|
||||||
|
|
||||||
|
// Check code
|
||||||
|
const { data: record, error } = await supabaseAdmin
|
||||||
|
.from('auth_codes')
|
||||||
|
.select('*')
|
||||||
|
.eq('email', user.email!)
|
||||||
|
.eq('code', code)
|
||||||
|
.gt('expires_at', new Date().toISOString())
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error || !record) {
|
||||||
|
return { error: "Geçersiz veya süresi dolmuş kod." }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success: Set Cookie
|
||||||
|
cookies().set('parakasa_2fa_verified', 'true', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 60 * 60 * 24 // 24 hours
|
||||||
|
})
|
||||||
|
|
||||||
|
// Delete used code
|
||||||
|
await supabaseAdmin.from('auth_codes').delete().eq('id', record.id)
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
BIN
lint-results.txt
Normal file
BIN
lint-results.txt
Normal file
Binary file not shown.
4
lint_errors.txt
Normal file
4
lint_errors.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
> parakasa@0.1.0 lint
|
||||||
|
> next lint
|
||||||
|
|
||||||
BIN
lint_output.txt
Normal file
BIN
lint_output.txt
Normal file
Binary file not shown.
BIN
lint_output_2.txt
Normal file
BIN
lint_output_2.txt
Normal file
Binary file not shown.
BIN
lint_output_3.txt
Normal file
BIN
lint_output_3.txt
Normal file
Binary file not shown.
BIN
lint_report.txt
Normal file
BIN
lint_report.txt
Normal file
Binary file not shown.
5
lint_results.txt
Normal file
5
lint_results.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
> parakasa@0.1.0 lint
|
||||||
|
> next lint
|
||||||
|
|
||||||
|
✔ No ESLint warnings or errors
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
|
|
||||||
-- Insert profile for the existing user and make them admin
|
|
||||||
insert into public.profiles (id, role, full_name)
|
|
||||||
select id, 'admin', 'Sistem Yöneticisi'
|
|
||||||
from auth.users
|
|
||||||
where email = 'kenankaraerr@hotmail.com'
|
|
||||||
on conflict (id) do update
|
|
||||||
set role = 'admin';
|
|
||||||
@@ -38,11 +38,19 @@ export async function middleware(request: NextRequest) {
|
|||||||
} = await supabase.auth.getUser();
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
// Protected routes
|
// Protected routes
|
||||||
if (!user && request.nextUrl.pathname.startsWith("/dashboard")) {
|
if (request.nextUrl.pathname.startsWith("/dashboard")) {
|
||||||
|
if (!user) {
|
||||||
return NextResponse.redirect(new URL("/login", request.url));
|
return NextResponse.redirect(new URL("/login", request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to dashboard if logged in and trying to access auth pages
|
// 2FA Check
|
||||||
|
const isVerified = request.cookies.get('parakasa_2fa_verified')?.value === 'true'
|
||||||
|
if (!isVerified) {
|
||||||
|
return NextResponse.redirect(new URL("/verify-2fa", request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to dashboard (or verify) if logged in
|
||||||
if (user && (request.nextUrl.pathname.startsWith("/login") || request.nextUrl.pathname.startsWith("/signup"))) {
|
if (user && (request.nextUrl.pathname.startsWith("/login") || request.nextUrl.pathname.startsWith("/signup"))) {
|
||||||
return NextResponse.redirect(new URL("/dashboard", request.url));
|
return NextResponse.redirect(new URL("/dashboard", request.url));
|
||||||
}
|
}
|
||||||
|
|||||||
5
migrations/add_product_code.sql
Normal file
5
migrations/add_product_code.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- Add product_code to products table
|
||||||
|
ALTER TABLE public.products ADD COLUMN IF NOT EXISTS product_code TEXT;
|
||||||
|
|
||||||
|
-- Create an index for faster lookups if needed (optional but good practice)
|
||||||
|
-- CREATE INDEX IF NOT EXISTS idx_products_product_code ON public.products(product_code);
|
||||||
14
migrations/add_sms_templates.sql
Normal file
14
migrations/add_sms_templates.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
-- SMS TEMPLATES
|
||||||
|
CREATE TABLE IF NOT EXISTS public.sms_templates (
|
||||||
|
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RLS Policies for SMS Templates
|
||||||
|
ALTER TABLE public.sms_templates ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can full access sms templates" ON public.sms_templates USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
|
||||||
|
);
|
||||||
345
migrations/schema_full.sql
Normal file
345
migrations/schema_full.sql
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
-- ParaKasa Consolidated Database Schema
|
||||||
|
-- Generated on 2026-01-29
|
||||||
|
-- This file contains the entire database structure, RLS policies, and storage setup.
|
||||||
|
|
||||||
|
-- 1. Enable Extensions
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
-- 2. Tables
|
||||||
|
|
||||||
|
-- PROFILES
|
||||||
|
CREATE TABLE IF NOT EXISTS public.profiles (
|
||||||
|
id UUID REFERENCES auth.users ON DELETE CASCADE PRIMARY KEY,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')),
|
||||||
|
full_name TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- SITE SETTINGS
|
||||||
|
CREATE TABLE IF NOT EXISTS public.site_settings (
|
||||||
|
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
|
site_title TEXT NOT NULL DEFAULT 'ParaKasa',
|
||||||
|
site_description TEXT,
|
||||||
|
contact_email TEXT,
|
||||||
|
contact_phone TEXT,
|
||||||
|
logo_url TEXT,
|
||||||
|
currency TEXT DEFAULT 'TRY',
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- SITE CONTENTS (Dynamic CMS)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.site_contents (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
type TEXT CHECK (type IN ('text', 'image_url', 'html', 'long_text', 'json')),
|
||||||
|
section TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CATEGORIES
|
||||||
|
CREATE TABLE IF NOT EXISTS public.categories (
|
||||||
|
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
image_url TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- PRODUCTS
|
||||||
|
CREATE TABLE IF NOT EXISTS public.products (
|
||||||
|
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL, -- Legacy text field, kept for compatibility
|
||||||
|
category_id UUID REFERENCES public.categories(id) ON DELETE SET NULL, -- Foreign key relation
|
||||||
|
description TEXT,
|
||||||
|
image_url TEXT,
|
||||||
|
price DECIMAL(10,2),
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_category_id ON public.products(category_id);
|
||||||
|
|
||||||
|
-- PRODUCT IMAGES (Multi-image support)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.product_images (
|
||||||
|
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
product_id BIGINT REFERENCES public.products(id) ON DELETE CASCADE NOT NULL,
|
||||||
|
image_url TEXT NOT NULL,
|
||||||
|
display_order INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_product_images_product_id ON public.product_images(product_id);
|
||||||
|
|
||||||
|
-- SLIDERS
|
||||||
|
CREATE TABLE IF NOT EXISTS public.sliders (
|
||||||
|
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
image_url TEXT NOT NULL,
|
||||||
|
link TEXT,
|
||||||
|
"order" INTEGER DEFAULT 0,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CUSTOMERS
|
||||||
|
CREATE TABLE IF NOT EXISTS public.customers (
|
||||||
|
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||||
|
full_name TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
address TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- SMS SETTINGS
|
||||||
|
CREATE TABLE IF NOT EXISTS public.sms_settings (
|
||||||
|
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
provider TEXT DEFAULT 'netgsm',
|
||||||
|
api_url TEXT DEFAULT 'https://api.netgsm.com.tr/sms/send/get',
|
||||||
|
username TEXT,
|
||||||
|
password TEXT,
|
||||||
|
header TEXT,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- SMS LOGS
|
||||||
|
CREATE TABLE IF NOT EXISTS public.sms_logs (
|
||||||
|
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
phone TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
status TEXT, -- 'success' or 'error'
|
||||||
|
response_code TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AUTH CODES (2FA / Verification)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.auth_codes (
|
||||||
|
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_codes_email ON public.auth_codes(email);
|
||||||
|
|
||||||
|
|
||||||
|
-- 3. Row Level Security (RLS) & Policies
|
||||||
|
|
||||||
|
-- Enable RLS
|
||||||
|
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.site_settings ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.site_contents ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.categories ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.products ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.product_images ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.sliders ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.customers ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.sms_settings ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.sms_logs ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE public.auth_codes ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Helper function for admin check (optional, but cleaner if used commonly)
|
||||||
|
-- For this script, we'll use the EXISTS subquery pattern directly to ensure portability.
|
||||||
|
|
||||||
|
-- PROFILES POLICIES
|
||||||
|
CREATE POLICY "Public profiles are viewable by everyone."
|
||||||
|
ON public.profiles FOR SELECT USING ( true );
|
||||||
|
|
||||||
|
CREATE POLICY "Users can insert their own profile."
|
||||||
|
ON public.profiles FOR INSERT WITH CHECK ( auth.uid() = id );
|
||||||
|
|
||||||
|
CREATE POLICY "Users can update own profile."
|
||||||
|
ON public.profiles FOR UPDATE USING ( auth.uid() = id );
|
||||||
|
|
||||||
|
-- SITE SETTINGS POLICIES
|
||||||
|
CREATE POLICY "Site settings are viewable by everyone."
|
||||||
|
ON public.site_settings FOR SELECT USING ( true );
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can update site settings."
|
||||||
|
ON public.site_settings FOR UPDATE USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- SITE CONTENTS POLICIES
|
||||||
|
CREATE POLICY "Public read access"
|
||||||
|
ON public.site_contents FOR SELECT USING (true);
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can insert site contents"
|
||||||
|
ON public.site_contents FOR INSERT WITH CHECK (
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can update site contents"
|
||||||
|
ON public.site_contents FOR UPDATE USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CATEGORIES POLICIES
|
||||||
|
CREATE POLICY "Public categories are viewable by everyone."
|
||||||
|
ON public.categories FOR SELECT USING ( true );
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can insert categories."
|
||||||
|
ON public.categories FOR INSERT WITH CHECK (
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can update categories."
|
||||||
|
ON public.categories FOR UPDATE USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can delete categories."
|
||||||
|
ON public.categories FOR DELETE USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- PRODUCTS POLICIES
|
||||||
|
CREATE POLICY "Public products are viewable by everyone."
|
||||||
|
ON public.products FOR SELECT USING ( true );
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can insert products"
|
||||||
|
ON public.products FOR INSERT WITH CHECK (
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can update products"
|
||||||
|
ON public.products FOR UPDATE USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can delete products"
|
||||||
|
ON public.products FOR DELETE USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- PRODUCT IMAGES POLICIES (Inherit from products basically, or admin only)
|
||||||
|
CREATE POLICY "Public product images are viewable."
|
||||||
|
ON public.product_images FOR SELECT USING ( true );
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can insert product images"
|
||||||
|
ON public.product_images FOR INSERT WITH CHECK (
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can delete product images"
|
||||||
|
ON public.product_images FOR DELETE USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- SLIDERS POLICIES
|
||||||
|
CREATE POLICY "Public sliders are viewable by everyone."
|
||||||
|
ON public.sliders FOR SELECT USING ( true );
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can insert sliders."
|
||||||
|
ON public.sliders FOR INSERT WITH CHECK (
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can update sliders."
|
||||||
|
ON public.sliders FOR UPDATE USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can delete sliders."
|
||||||
|
ON public.sliders FOR DELETE USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CUSTOMERS POLICIES
|
||||||
|
CREATE POLICY "Admins can view customers"
|
||||||
|
ON public.customers FOR SELECT USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can insert customers"
|
||||||
|
ON public.customers FOR INSERT WITH CHECK (
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can update customers"
|
||||||
|
ON public.customers FOR UPDATE USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can delete customers"
|
||||||
|
ON public.customers FOR DELETE USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- SMS SETTINGS/LOGS POLICIES
|
||||||
|
CREATE POLICY "Admins can full access sms" ON public.sms_settings USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
|
||||||
|
);
|
||||||
|
CREATE POLICY "Admins can full access sms logs" ON public.sms_logs USING (
|
||||||
|
EXISTS (SELECT 1 FROM public.profiles WHERE profiles.id = auth.uid() AND profiles.role = 'admin')
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- 4. Initial Data
|
||||||
|
|
||||||
|
-- Site Settings Default
|
||||||
|
INSERT INTO public.site_settings (site_title, contact_email)
|
||||||
|
SELECT 'ParaKasa', 'info@parakasa.com'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM public.site_settings);
|
||||||
|
|
||||||
|
-- Site Contents Defaults
|
||||||
|
INSERT INTO public.site_contents (key, value, type, section) VALUES
|
||||||
|
('site_title', 'ParaKasa', 'text', 'general'),
|
||||||
|
('site_description', 'ParaKasa Yönetim Paneli', 'long_text', 'general'),
|
||||||
|
('site_logo', '', 'image_url', 'general'),
|
||||||
|
('contact_phone', '', 'text', 'contact'),
|
||||||
|
('contact_email', '', 'text', 'contact'),
|
||||||
|
('contact_address', '', 'long_text', 'contact'),
|
||||||
|
('social_instagram', '', 'text', 'contact'),
|
||||||
|
('social_youtube', '', 'text', 'contact'),
|
||||||
|
('social_tiktok', '', 'text', 'contact'),
|
||||||
|
('contact_map_embed', '', 'html', 'contact')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
|
||||||
|
-- 5. Storage Buckets & Policies
|
||||||
|
|
||||||
|
-- Buckets
|
||||||
|
INSERT INTO storage.buckets (id, name, public) VALUES ('products', 'products', true) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO storage.buckets (id, name, public) VALUES ('categories', 'categories', true) ON CONFLICT (id) DO NOTHING;
|
||||||
|
INSERT INTO storage.buckets (id, name, public) VALUES ('sliders', 'sliders', true) ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Drop existing policies to avoid duplicates if re-running
|
||||||
|
DROP POLICY IF EXISTS "Public Access Products" ON storage.objects;
|
||||||
|
DROP POLICY IF EXISTS "Auth Upload Products" ON storage.objects;
|
||||||
|
DROP POLICY IF EXISTS "Auth Update Products" ON storage.objects;
|
||||||
|
DROP POLICY IF EXISTS "Auth Delete Products" ON storage.objects;
|
||||||
|
|
||||||
|
-- Products Policies
|
||||||
|
CREATE POLICY "Public Access Products" ON storage.objects FOR SELECT USING ( bucket_id = 'products' );
|
||||||
|
CREATE POLICY "Auth Upload Products" ON storage.objects FOR INSERT WITH CHECK ( bucket_id = 'products' AND auth.role() = 'authenticated' );
|
||||||
|
CREATE POLICY "Auth Update Products" ON storage.objects FOR UPDATE USING ( bucket_id = 'products' AND auth.role() = 'authenticated' );
|
||||||
|
CREATE POLICY "Auth Delete Products" ON storage.objects FOR DELETE USING ( bucket_id = 'products' AND auth.role() = 'authenticated' );
|
||||||
|
|
||||||
|
-- Categories Policies
|
||||||
|
DROP POLICY IF EXISTS "Public Access Categories" ON storage.objects;
|
||||||
|
DROP POLICY IF EXISTS "Auth Upload Categories" ON storage.objects;
|
||||||
|
DROP POLICY IF EXISTS "Auth Update Categories" ON storage.objects;
|
||||||
|
DROP POLICY IF EXISTS "Auth Delete Categories" ON storage.objects;
|
||||||
|
|
||||||
|
CREATE POLICY "Public Access Categories" ON storage.objects FOR SELECT USING ( bucket_id = 'categories' );
|
||||||
|
CREATE POLICY "Auth Upload Categories" ON storage.objects FOR INSERT WITH CHECK ( bucket_id = 'categories' AND auth.role() = 'authenticated' );
|
||||||
|
CREATE POLICY "Auth Update Categories" ON storage.objects FOR UPDATE USING ( bucket_id = 'categories' AND auth.role() = 'authenticated' );
|
||||||
|
CREATE POLICY "Auth Delete Categories" ON storage.objects FOR DELETE USING ( bucket_id = 'categories' AND auth.role() = 'authenticated' );
|
||||||
|
|
||||||
|
-- Sliders Policies
|
||||||
|
DROP POLICY IF EXISTS "Public Access Sliders" ON storage.objects;
|
||||||
|
DROP POLICY IF EXISTS "Auth Upload Sliders" ON storage.objects;
|
||||||
|
DROP POLICY IF EXISTS "Auth Update Sliders" ON storage.objects;
|
||||||
|
DROP POLICY IF EXISTS "Auth Delete Sliders" ON storage.objects;
|
||||||
|
|
||||||
|
CREATE POLICY "Public Access Sliders" ON storage.objects FOR SELECT USING ( bucket_id = 'sliders' );
|
||||||
|
CREATE POLICY "Auth Upload Sliders" ON storage.objects FOR INSERT WITH CHECK ( bucket_id = 'sliders' AND auth.role() = 'authenticated' );
|
||||||
|
CREATE POLICY "Auth Update Sliders" ON storage.objects FOR UPDATE USING ( bucket_id = 'sliders' AND auth.role() = 'authenticated' );
|
||||||
|
CREATE POLICY "Auth Delete Sliders" ON storage.objects FOR DELETE USING ( bucket_id = 'sliders' AND auth.role() = 'authenticated' );
|
||||||
|
|
||||||
@@ -1,6 +1,18 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: '**.supabase.co',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'api.unsalcelikparakasalari.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
webpack: (config) => {
|
webpack: (config) => {
|
||||||
// Suppress cache serialization warnings
|
// Suppress cache serialization warnings
|
||||||
config.infrastructureLogging = {
|
config.infrastructureLogging = {
|
||||||
|
|||||||
8
nixpacks.toml
Normal file
8
nixpacks.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[phases.setup]
|
||||||
|
nixPkgs = ["nodejs"]
|
||||||
|
|
||||||
|
[phases.install]
|
||||||
|
cmds = ["npm ci"]
|
||||||
|
|
||||||
|
[phases.build]
|
||||||
|
cmds = ["npm run build"]
|
||||||
14
notlar.txt
Normal file
14
notlar.txt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Rehber api bilgisi
|
||||||
|
|
||||||
|
Telefon rehberi erişimi için planı hazırladım.
|
||||||
|
|
||||||
|
Bu özellik Contact Picker API kullanılarak yapılacak. Önemli Not: Bu özellik tarayıcı desteğine bağlıdır. Genellikle Android telefonlarda ve Chrome tarayıcıda sorunsuz çalışır. iPhone veya masaüstü bilgisayarlarda bu özellik tarayıcı tarafından desteklenmeyebilir. Desteklenmeyen cihazlarda bu butonu gizleyeceğiz.
|
||||||
|
|
||||||
|
Onaylıyorsanız kodlamaya geçiyorum.
|
||||||
|
|
||||||
|
|
||||||
|
// resim görüntülemek için düzgün açıklama
|
||||||
|
Tıklanabilir Telefon: İletişim sayfasındaki telefon numarasını link haline getireceğim. Mobilde tıkladığınızda direkt arama ekranı 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,16 +10,20 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@supabase/ssr": "^0.8.0",
|
"@supabase/ssr": "^0.8.0",
|
||||||
"@supabase/supabase-js": "^2.89.0",
|
"@supabase/supabase-js": "^2.89.0",
|
||||||
|
"browser-image-compression": "^2.0.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -30,20 +34,26 @@
|
|||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-hook-form": "^7.70.0",
|
"react-hook-form": "^7.70.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
|
"react-phone-number-input": "^3.4.14",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.2.16",
|
"eslint-config-next": "14.2.16",
|
||||||
|
"pg": "^8.17.2",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
@@ -61,6 +71,30 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@cspotcode/source-map-support": {
|
||||||
|
"version": "0.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||||
|
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/trace-mapping": "0.3.9"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
|
||||||
|
"version": "0.3.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||||
|
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/resolve-uri": "^3.0.3",
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.7.1",
|
"version": "1.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
|
||||||
@@ -664,6 +698,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-checkbox": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-collection": {
|
"node_modules/@radix-ui/react-collection": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||||
@@ -1213,6 +1277,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-scroll-area": {
|
||||||
|
"version": "1.2.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
|
||||||
|
"integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/number": "1.1.1",
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-select": {
|
"node_modules/@radix-ui/react-select": {
|
||||||
"version": "2.2.6",
|
"version": "2.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
||||||
@@ -1367,6 +1462,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-tabs": {
|
||||||
|
"version": "1.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||||
|
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-roving-focus": "1.1.11",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
@@ -1678,6 +1803,34 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tsconfig/node10": {
|
||||||
|
"version": "1.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
|
||||||
|
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@tsconfig/node12": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@tsconfig/node14": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@tsconfig/node16": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -1705,6 +1858,18 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pg": {
|
||||||
|
"version": "8.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz",
|
||||||
|
"integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"pg-protocol": "*",
|
||||||
|
"pg-types": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/phoenix": {
|
"node_modules/@types/phoenix": {
|
||||||
"version": "1.6.7",
|
"version": "1.6.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
|
||||||
@@ -2316,6 +2481,19 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/acorn-walk": {
|
||||||
|
"version": "8.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
|
||||||
|
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"acorn": "^8.11.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
@@ -2720,6 +2898,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/browser-image-compression": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/browser-image-compression/-/browser-image-compression-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"uzip": "0.20201231.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.1",
|
"version": "4.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||||
@@ -2922,6 +3109,12 @@
|
|||||||
"url": "https://polar.sh/cva"
|
"url": "https://polar.sh/cva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/classnames": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/client-only": {
|
"node_modules/client-only": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||||
@@ -2987,6 +3180,19 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/country-flag-icons": {
|
||||||
|
"version": "1.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.6.4.tgz",
|
||||||
|
"integrity": "sha512-Z3Zi419FI889tlElMsVhCIS5eRkiLDWixr576J5DPiTe5RGxpbRi+enMpHdYVp5iK5WFjr8P/RgyIFAGhFsiFg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/create-require": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -3167,6 +3373,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/diff": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dlv": {
|
"node_modules/dlv": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||||
@@ -3187,6 +3403,19 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "17.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||||
|
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -4504,6 +4733,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/input-format": {
|
||||||
|
"version": "0.3.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/input-format/-/input-format-0.3.14.tgz",
|
||||||
|
"integrity": "sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prop-types": "^15.8.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18.1.0",
|
||||||
|
"react-dom": ">=18.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||||
@@ -5128,6 +5378,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/libphonenumber-js": {
|
||||||
|
"version": "1.12.33",
|
||||||
|
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.33.tgz",
|
||||||
|
"integrity": "sha512-r9kw4OA6oDO4dPXkOrXTkArQAafIKAU71hChInV4FxZ69dxCfbwQGDPzqR5/vea94wU705/3AZroEbSoeVWrQw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lilconfig": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||||
@@ -5199,6 +5455,13 @@
|
|||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/make-error": {
|
||||||
|
"version": "1.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||||
|
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -5436,7 +5699,6 @@
|
|||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -5710,6 +5972,103 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.17.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.17.2.tgz",
|
||||||
|
"integrity": "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.10.1",
|
||||||
|
"pg-pool": "^3.11.0",
|
||||||
|
"pg-protocol": "^1.11.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.3.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.10.1.tgz",
|
||||||
|
"integrity": "sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
|
||||||
|
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
|
||||||
|
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -5922,6 +6281,49 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -5936,7 +6338,6 @@
|
|||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
@@ -6016,13 +6417,38 @@
|
|||||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-icons": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-phone-number-input": {
|
||||||
|
"version": "3.4.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-phone-number-input/-/react-phone-number-input-3.4.14.tgz",
|
||||||
|
"integrity": "sha512-T9MziNuvthzv6+JAhKD71ab/jVXW5U20nQZRBJd6+q+ujmkC+/ISOf2GYo8pIi4VGjdIYRIHDftMAYn3WKZT3w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"classnames": "^2.5.1",
|
||||||
|
"country-flag-icons": "^1.5.17",
|
||||||
|
"input-format": "^0.3.14",
|
||||||
|
"libphonenumber-js": "^1.12.27",
|
||||||
|
"prop-types": "^15.8.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8",
|
||||||
|
"react-dom": ">=16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-remove-scroll": {
|
"node_modules/react-remove-scroll": {
|
||||||
"version": "2.7.2",
|
"version": "2.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||||
@@ -6531,6 +6957,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stable-hash": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||||
@@ -7034,6 +7470,57 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-node": {
|
||||||
|
"version": "10.9.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||||
|
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@cspotcode/source-map-support": "^0.8.0",
|
||||||
|
"@tsconfig/node10": "^1.0.7",
|
||||||
|
"@tsconfig/node12": "^1.0.7",
|
||||||
|
"@tsconfig/node14": "^1.0.0",
|
||||||
|
"@tsconfig/node16": "^1.0.2",
|
||||||
|
"acorn": "^8.4.1",
|
||||||
|
"acorn-walk": "^8.1.1",
|
||||||
|
"arg": "^4.1.0",
|
||||||
|
"create-require": "^1.1.0",
|
||||||
|
"diff": "^4.0.1",
|
||||||
|
"make-error": "^1.1.1",
|
||||||
|
"v8-compile-cache-lib": "^3.0.1",
|
||||||
|
"yn": "3.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"ts-node": "dist/bin.js",
|
||||||
|
"ts-node-cwd": "dist/bin-cwd.js",
|
||||||
|
"ts-node-esm": "dist/bin-esm.js",
|
||||||
|
"ts-node-script": "dist/bin-script.js",
|
||||||
|
"ts-node-transpile-only": "dist/bin-transpile.js",
|
||||||
|
"ts-script": "dist/bin-script-deprecated.js"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@swc/core": ">=1.2.50",
|
||||||
|
"@swc/wasm": ">=1.2.50",
|
||||||
|
"@types/node": "*",
|
||||||
|
"typescript": ">=2.7"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@swc/core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@swc/wasm": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ts-node/node_modules/arg": {
|
||||||
|
"version": "4.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||||
|
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tsconfig-paths": {
|
"node_modules/tsconfig-paths": {
|
||||||
"version": "3.15.0",
|
"version": "3.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
||||||
@@ -7341,6 +7828,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/uzip": {
|
||||||
|
"version": "0.20201231.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uzip/-/uzip-0.20201231.0.tgz",
|
||||||
|
"integrity": "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/v8-compile-cache-lib": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -7585,6 +8085,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yn": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
15
package.json
15
package.json
@@ -6,21 +6,28 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20.x"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@supabase/ssr": "^0.8.0",
|
"@supabase/ssr": "^0.8.0",
|
||||||
"@supabase/supabase-js": "^2.89.0",
|
"@supabase/supabase-js": "^2.89.0",
|
||||||
|
"browser-image-compression": "^2.0.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@@ -31,20 +38,26 @@
|
|||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-hook-form": "^7.70.0",
|
"react-hook-form": "^7.70.0",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
|
"react-phone-number-input": "^3.4.14",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.2.16",
|
"eslint-config-next": "14.2.16",
|
||||||
|
"pg": "^8.17.2",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/avatars/01.png
Normal file
BIN
public/avatars/01.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 379 KiB |
BIN
public/fonts/Inter-Black.ttf
Normal file
BIN
public/fonts/Inter-Black.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Inter-Bold.ttf
Normal file
BIN
public/fonts/Inter-Bold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Inter-ExtraBold.ttf
Normal file
BIN
public/fonts/Inter-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Inter-ExtraLight.ttf
Normal file
BIN
public/fonts/Inter-ExtraLight.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Inter-Light.ttf
Normal file
BIN
public/fonts/Inter-Light.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Inter-Medium.ttf
Normal file
BIN
public/fonts/Inter-Medium.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Inter-Regular.ttf
Normal file
BIN
public/fonts/Inter-Regular.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Inter-SemiBold.ttf
Normal file
BIN
public/fonts/Inter-SemiBold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Inter-Thin.ttf
Normal file
BIN
public/fonts/Inter-Thin.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Outfit-Black.ttf
Normal file
BIN
public/fonts/Outfit-Black.ttf
Normal file
Binary file not shown.
BIN
public/fonts/Outfit-Bold.ttf
Normal file
BIN
public/fonts/Outfit-Bold.ttf
Normal file
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