sms entegrasyonu ve ana sayfa işlemleri

This commit is contained in:
2026-01-26 00:19:09 +03:00
parent 1e1baa84ff
commit 5c34df0f09
19 changed files with 1018 additions and 17 deletions

View File

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

View File

@@ -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

View File

@@ -0,0 +1,65 @@
import { getSmsLogs } from "@/lib/sms/actions"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { format } from "date-fns"
import { tr } from "date-fns/locale"
export default async function SmsLogsPage() {
const { data: logs, error } = await getSmsLogs(100)
if (error) {
return <div className="p-8">Hata: {error}</div>
}
return (
<div className="flex-1 space-y-4 p-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>
)
}

View File

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

View File

@@ -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>

View File

@@ -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) {

View File

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

View File

@@ -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>
) )

View File

@@ -0,0 +1,198 @@
'use client'
import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { 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>
)
}

View File

@@ -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

View File

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

View File

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

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

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

View File

@@ -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
View File

@@ -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",

View File

@@ -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
View 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'
)
);

View 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
View 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>