sms entegrasyonu ve ana sayfa işlemleri
This commit is contained in:
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -28,6 +28,15 @@ export default async function SettingsPage() {
|
|||||||
{ key: 'site_title', value: 'ParaKasa', type: 'text', section: 'general' },
|
{ key: 'site_title', value: 'ParaKasa', type: 'text', section: 'general' },
|
||||||
{ key: 'site_description', value: '', type: 'long_text', section: 'general' },
|
{ key: 'site_description', value: '', type: 'long_text', section: 'general' },
|
||||||
{ key: 'site_logo', value: '', type: 'image_url', 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
|
// Contact
|
||||||
{ key: 'contact_phone', value: '', type: 'text', section: 'contact' },
|
{ key: 'contact_phone', value: '', type: 'text', section: 'contact' },
|
||||||
@@ -37,6 +46,14 @@ export default async function SettingsPage() {
|
|||||||
{ key: 'social_youtube', value: '', type: 'text', section: 'contact' },
|
{ key: 'social_youtube', value: '', type: 'text', section: 'contact' },
|
||||||
{ key: 'social_tiktok', value: '', type: 'text', section: 'contact' },
|
{ key: 'social_tiktok', value: '', type: 'text', section: 'contact' },
|
||||||
{ key: 'contact_map_embed', value: '', type: 'html', 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
|
// Merge default contents with existing contents
|
||||||
|
|||||||
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-8 pt-6">
|
||||||
|
<h2 className="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">
|
||||||
|
<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 || []} />
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ import { ArrowRight, ShieldCheck, Lock, History, LayoutDashboard } from "lucide-
|
|||||||
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>
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const SECTIONS = [
|
|||||||
{ id: 'general', label: 'Genel Ayarlar' },
|
{ id: 'general', label: 'Genel Ayarlar' },
|
||||||
{ id: 'home', label: 'Anasayfa' },
|
{ id: 'home', label: 'Anasayfa' },
|
||||||
{ id: 'contact', label: 'İletişim' },
|
{ id: 'contact', label: 'İletişim' },
|
||||||
|
{ id: 'seo', label: 'SEO Ayarları' },
|
||||||
|
{ id: 'scripts', label: 'Script & Analitik' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function ContentForm({ initialContent }: ContentFormProps) {
|
export function ContentForm({ initialContent }: ContentFormProps) {
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { LayoutDashboard, Package, ShoppingCart, Settings, Globe, Tags } from "lucide-react"
|
import { LayoutDashboard, Package, ShoppingCart, Settings, Globe, Tags, Users, MessageSquare, History } from "lucide-react"
|
||||||
|
|
||||||
const sidebarItems = [
|
const sidebarItems = [
|
||||||
{
|
{
|
||||||
@@ -16,15 +16,25 @@ const sidebarItems = [
|
|||||||
href: "/dashboard/products",
|
href: "/dashboard/products",
|
||||||
icon: Package,
|
icon: Package,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Kategoriler",
|
||||||
|
href: "/dashboard/categories",
|
||||||
|
icon: Tags,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Siparişler",
|
title: "Siparişler",
|
||||||
href: "/dashboard/orders",
|
href: "/dashboard/orders",
|
||||||
icon: ShoppingCart,
|
icon: ShoppingCart,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Kategoriler",
|
title: "Müşteriler",
|
||||||
href: "/dashboard/categories",
|
href: "/dashboard/customers",
|
||||||
icon: Tags,
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "SMS Gönder",
|
||||||
|
href: "/dashboard/sms",
|
||||||
|
icon: MessageSquare,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Ayarlar",
|
title: "Ayarlar",
|
||||||
@@ -51,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}
|
||||||
@@ -66,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>
|
||||||
)
|
)
|
||||||
|
|||||||
198
components/dashboard/sms-page-client.tsx
Normal file
198
components/dashboard/sms-page-client.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 { Customer } from "@/types/customer"
|
||||||
|
import { sendBulkSms } from "@/lib/sms/actions"
|
||||||
|
|
||||||
|
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 { Label } from "@/components/ui/label"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
|
||||||
|
import { Loader2, Send } 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 [selectAll, setSelectAll] = useState(false)
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
manualNumbers: "",
|
||||||
|
message: "",
|
||||||
|
selectedCustomers: []
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSelectAll = (checked: boolean) => {
|
||||||
|
setSelectAll(checked)
|
||||||
|
if (checked) {
|
||||||
|
const allPhones = customers.map(c => c.phone).filter(Boolean) as string[]
|
||||||
|
form.setValue('selectedCustomers', allPhones)
|
||||||
|
} else {
|
||||||
|
form.setValue('selectedCustomers', [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
const manualPhones = values.manualNumbers
|
||||||
|
?.split(/[,\n]/) // Split by comma or newline
|
||||||
|
.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()
|
||||||
|
setSelectAll(false)
|
||||||
|
} 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-8 pt-6">
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">SMS Gönderimi</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<Card>
|
||||||
|
<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">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Manuel Numaralar</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="5551234567, 5329876543 (Virgül veya alt satır ile ayırın)"
|
||||||
|
{...form.register("manualNumbers")}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Veritabanında olmayan numaraları buraya girebilirsiniz.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Gönderilecek Mesaj</Label>
|
||||||
|
<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="pt-4">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Card className="h-full flex flex-col">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Müşteri Listesi</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Listeden toplu seçim yapabilirsiniz.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 min-h-[400px]">
|
||||||
|
<div className="flex items-center space-x-2 mb-4 pb-4 border-b">
|
||||||
|
<Checkbox
|
||||||
|
id="select-all"
|
||||||
|
checked={selectAll}
|
||||||
|
onCheckedChange={handleSelectAll}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="select-all" className="font-bold">Tümünü Seç ({customers.length})</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[400px] w-full pr-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{customers.length === 0 && <p className="text-muted-foreground">Kayıtlı müşteri bulunamadı.</p>}
|
||||||
|
{customers.map((customer) => (
|
||||||
|
<div key={customer.id} className="flex items-start space-x-2 py-2 hover:bg-slate-50 dark:hover:bg-slate-900 rounded px-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`customer-${customer.id}`}
|
||||||
|
checked={watchedSelected.includes(customer.phone || "")}
|
||||||
|
disabled={!customer.phone}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const current = form.getValues("selectedCustomers") || []
|
||||||
|
if (checked) {
|
||||||
|
form.setValue("selectedCustomers", [...current, customer.phone || ""])
|
||||||
|
} else {
|
||||||
|
form.setValue("selectedCustomers", current.filter(p => p !== customer.phone))
|
||||||
|
setSelectAll(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-1.5 leading-none">
|
||||||
|
<Label
|
||||||
|
htmlFor={`customer-${customer.id}`}
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{customer.full_name}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{customer.phone || "Telefon Yok"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -88,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
|
||||||
|
|||||||
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 }
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -93,6 +93,7 @@ export async function updateSmsSettings(data: {
|
|||||||
|
|
||||||
revalidatePath("/dashboard/settings")
|
revalidatePath("/dashboard/settings")
|
||||||
return { success: true }
|
return { success: true }
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { error: (error as Error).message }
|
return { error: (error as Error).message }
|
||||||
}
|
}
|
||||||
@@ -133,3 +134,70 @@ export async function sendTestSms(phone: string) {
|
|||||||
return { error: (error as Error).message }
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"@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",
|
||||||
@@ -1248,6 +1249,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",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@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",
|
||||||
|
|||||||
94
security_updates.sql
Normal file
94
security_updates.sql
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
-- SECURITY UPDATES
|
||||||
|
-- This script strengthens the RLS policies by enforcing 'admin' role checks
|
||||||
|
-- instead of just checking if the user is authenticated.
|
||||||
|
|
||||||
|
-- 1. PRODUCTS TABLE
|
||||||
|
-- Drop existing loose policies
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can insert products." ON products;
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can update products." ON products;
|
||||||
|
DROP POLICY IF EXISTS "Authenticated users can delete products." ON products;
|
||||||
|
|
||||||
|
-- Create strict admin policies
|
||||||
|
CREATE POLICY "Admins can insert products"
|
||||||
|
ON products FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
exists (
|
||||||
|
select 1 from profiles
|
||||||
|
where profiles.id = auth.uid() and profiles.role = 'admin'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can update products"
|
||||||
|
ON products FOR UPDATE
|
||||||
|
USING (
|
||||||
|
exists (
|
||||||
|
select 1 from profiles
|
||||||
|
where profiles.id = auth.uid() and profiles.role = 'admin'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Admins can delete products"
|
||||||
|
ON products FOR DELETE
|
||||||
|
USING (
|
||||||
|
exists (
|
||||||
|
select 1 from profiles
|
||||||
|
where profiles.id = auth.uid() and profiles.role = 'admin'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- 2. CUSTOMERS TABLE
|
||||||
|
-- Drop existing loose policies (if they match the previous loose pattern)
|
||||||
|
DROP POLICY IF EXISTS "Admins can insert customers" ON customers;
|
||||||
|
DROP POLICY IF EXISTS "Admins can update customers" ON customers;
|
||||||
|
DROP POLICY IF EXISTS "Admins can delete customers" ON customers;
|
||||||
|
|
||||||
|
-- Re-create strict policies (just to be sure, ensuring the subquery check is present)
|
||||||
|
CREATE POLICY "Strict Admin Insert Customers"
|
||||||
|
ON customers FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
exists (
|
||||||
|
select 1 from profiles
|
||||||
|
where profiles.id = auth.uid() and profiles.role = 'admin'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Strict Admin Update Customers"
|
||||||
|
ON customers FOR UPDATE
|
||||||
|
USING (
|
||||||
|
exists (
|
||||||
|
select 1 from profiles
|
||||||
|
where profiles.id = auth.uid() and profiles.role = 'admin'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Strict Admin Delete Customers"
|
||||||
|
ON customers FOR DELETE
|
||||||
|
USING (
|
||||||
|
exists (
|
||||||
|
select 1 from profiles
|
||||||
|
where profiles.id = auth.uid() and profiles.role = 'admin'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. SITE CONTENTS TABLE
|
||||||
|
DROP POLICY IF EXISTS "Admin update access" ON site_contents;
|
||||||
|
DROP POLICY IF EXISTS "Admin insert access" ON site_contents;
|
||||||
|
|
||||||
|
CREATE POLICY "Strict Admin Update Site Contents"
|
||||||
|
ON site_contents FOR UPDATE
|
||||||
|
USING (
|
||||||
|
exists (
|
||||||
|
select 1 from profiles
|
||||||
|
where profiles.id = auth.uid() and profiles.role = 'admin'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Strict Admin Insert Site Contents"
|
||||||
|
ON site_contents FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
exists (
|
||||||
|
select 1 from profiles
|
||||||
|
where profiles.id = auth.uid() and profiles.role = 'admin'
|
||||||
|
)
|
||||||
|
);
|
||||||
35
supabase_schema_customers.sql
Normal file
35
supabase_schema_customers.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
-- Create customers table
|
||||||
|
CREATE TABLE IF NOT EXISTS 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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Enable Row Level Security
|
||||||
|
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Policies
|
||||||
|
-- 1. Admin read access
|
||||||
|
CREATE POLICY "Admins can view customers"
|
||||||
|
ON customers FOR SELECT
|
||||||
|
USING (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
-- 2. Admin insert access
|
||||||
|
CREATE POLICY "Admins can insert customers"
|
||||||
|
ON customers FOR INSERT
|
||||||
|
WITH CHECK (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
-- 3. Admin update access
|
||||||
|
CREATE POLICY "Admins can update customers"
|
||||||
|
ON customers FOR UPDATE
|
||||||
|
USING (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
-- 4. Admin delete access
|
||||||
|
CREATE POLICY "Admins can delete customers"
|
||||||
|
ON customers FOR DELETE
|
||||||
|
USING (auth.role() = 'authenticated');
|
||||||
13
types/customer.ts
Normal file
13
types/customer.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export interface Customer {
|
||||||
|
id: number
|
||||||
|
full_name: string
|
||||||
|
email?: string | null
|
||||||
|
phone?: string | null
|
||||||
|
address?: string | null
|
||||||
|
notes?: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CustomerInsert = Omit<Customer, 'id' | 'created_at' | 'updated_at'>
|
||||||
|
export type CustomerUpdate = Partial<CustomerInsert>
|
||||||
Reference in New Issue
Block a user