add features
This commit is contained in:
parent
878f2994be
commit
c7960f598c
|
|
@ -0,0 +1,114 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Star, Trash2, Calendar, User, MessageCircle } from 'lucide-react'
|
||||||
|
import { deleteReview } from './action-admin-review'
|
||||||
|
import { showSwal } from '@/lib/swal'
|
||||||
|
|
||||||
|
interface Review {
|
||||||
|
id: string
|
||||||
|
rating: number
|
||||||
|
ulasan: string
|
||||||
|
nama_pengulas: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
posyanduId: string
|
||||||
|
initialReviews: Review[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminReviewList({ posyanduId, initialReviews }: Props) {
|
||||||
|
const [isDeleting, setIsDeleting] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleDelete = async (reviewId: string) => {
|
||||||
|
const result = await showSwal.confirm(
|
||||||
|
'Hapus Ulasan?',
|
||||||
|
'Apakah Anda yakin ingin menghapus ulasan ini? Tindakan ini tidak dapat dibatalkan.'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.isConfirmed) return
|
||||||
|
|
||||||
|
setIsDeleting(reviewId)
|
||||||
|
try {
|
||||||
|
const res = await deleteReview(reviewId, posyanduId)
|
||||||
|
if (res.success) {
|
||||||
|
showSwal.success('Berhasil!', 'Ulasan telah dihapus.')
|
||||||
|
} else {
|
||||||
|
showSwal.error('Gagal!', res.error || 'Gagal menghapus ulasan.')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showSwal.error('Gagal!', 'Terjadi kesalahan sistem.')
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-3xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] overflow-hidden">
|
||||||
|
<div className="p-6 border-b-2 border-black bg-purple-600 flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-black text-white flex items-center gap-3 uppercase">
|
||||||
|
<MessageCircle className="w-6 h-6" />
|
||||||
|
Ulasan Masyarakat
|
||||||
|
</h3>
|
||||||
|
<span className="bg-white text-purple-600 px-3 py-1 rounded-full text-xs font-black border-2 border-black">
|
||||||
|
{initialReviews.length} TOTAL
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{initialReviews.length > 0 ? (
|
||||||
|
initialReviews.map((rev) => (
|
||||||
|
<div key={rev.id} className="p-8 hover:bg-gray-50/50 transition-all flex flex-col gap-4">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-purple-100 text-purple-600 rounded-2xl flex items-center justify-center font-black text-xl border-2 border-purple-200">
|
||||||
|
{rev.nama_pengulas?.[0] || 'A'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-gray-900 text-lg">{rev.nama_pengulas || 'Orang Tua'}</p>
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
className={`w-3.5 h-3.5 ${i < rev.rating ? 'text-yellow-400 fill-current' : 'text-gray-200'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="w-1 h-1 bg-gray-300 rounded-full"></div>
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-gray-400 font-bold uppercase tracking-widest">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{new Date(rev.created_at).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(rev.id)}
|
||||||
|
disabled={isDeleting === rev.id}
|
||||||
|
className="p-3 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all border border-transparent hover:border-red-100 disabled:opacity-50"
|
||||||
|
title="Hapus Ulasan"
|
||||||
|
>
|
||||||
|
<Trash2 className={`w-5 h-5 ${isDeleting === rev.id ? 'animate-pulse' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 rounded-2xl p-5 border-2 border-gray-100 relative">
|
||||||
|
<p className="text-gray-700 font-semibold italic leading-relaxed text-sm">
|
||||||
|
"{rev.ulasan}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="p-20 text-center flex flex-col items-center gap-4 text-gray-300">
|
||||||
|
<MessageCircle className="w-16 h-16 opacity-10" />
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="font-black text-xl">Belum Ada Ulasan</p>
|
||||||
|
<p className="text-sm font-semibold opacity-60">Posyandu ini belum memiliki riwayat ulasan dari masyarakat.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
'use server'
|
||||||
|
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
|
||||||
|
export async function deleteReview(reviewId: string, posyanduId: string) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('ulasan_posyandu')
|
||||||
|
.delete()
|
||||||
|
.eq('id', reviewId)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error deleting review:', error)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/dashboard/manajemen-posyandu/review/${posyanduId}`)
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,9 @@ import { cookies } from 'next/headers'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { supabase } from '@/lib/supabase'
|
import { supabase } from '@/lib/supabase'
|
||||||
import { LogoutButton } from '@/components/logout-button'
|
import { LogoutButton } from '@/components/logout-button'
|
||||||
import { ArrowLeft, Building2, MapPin, Phone, User, ExternalLink, Calendar, Map as MapIcon } from 'lucide-react'
|
import { ArrowLeft, Building2, MapPin, User, ExternalLink, Map as MapIcon } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { AdminReviewList } from './AdminReviewList'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
|
|
@ -22,7 +23,8 @@ export default async function ReviewPosyanduPage({ params }: Props) {
|
||||||
.from('detail_posyandu')
|
.from('detail_posyandu')
|
||||||
.select(`
|
.select(`
|
||||||
*,
|
*,
|
||||||
petugas:petugas_posyandu_lokal(*)
|
petugas:petugas_posyandu_lokal(*),
|
||||||
|
reviews:ulasan_posyandu(*)
|
||||||
`)
|
`)
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.single()
|
.single()
|
||||||
|
|
@ -89,7 +91,7 @@ export default async function ReviewPosyanduPage({ params }: Props) {
|
||||||
<div className="p-6 border-b border-gray-100 flex items-center justify-between">
|
<div className="p-6 border-b border-gray-100 flex items-center justify-between">
|
||||||
<h3 className="text-base font-black flex items-center gap-3">
|
<h3 className="text-base font-black flex items-center gap-3">
|
||||||
<User className="w-5 h-5 text-purple-600" />
|
<User className="w-5 h-5 text-purple-600" />
|
||||||
Daftarkan Petugas Bertugas
|
Daftar Petugas Terdaftar
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-xs font-bold text-purple-600 bg-purple-50 px-3 py-1 rounded-full border border-purple-100 uppercase tracking-widest">
|
<span className="text-xs font-bold text-purple-600 bg-purple-50 px-3 py-1 rounded-full border border-purple-100 uppercase tracking-widest">
|
||||||
{posyandu.petugas?.length || 0} Orang
|
{posyandu.petugas?.length || 0} Orang
|
||||||
|
|
@ -123,6 +125,12 @@ export default async function ReviewPosyanduPage({ params }: Props) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Admin Review List Card */}
|
||||||
|
<AdminReviewList
|
||||||
|
posyanduId={posyandu.id}
|
||||||
|
initialReviews={posyandu.reviews || []}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column - Map & Quick Sync */}
|
{/* Right Column - Map & Quick Sync */}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "1",
|
||||||
|
"title": "Stunting: Apa, Penyebab, dan Upaya Penanganannya",
|
||||||
|
"category": "Edukasi Dasar",
|
||||||
|
"readTime": "5 min",
|
||||||
|
"source": "Kemenkes RI",
|
||||||
|
"description": "Pelajari definisi stunting, penyebab utama seperti malnutrisi kronis, serta langkah-langkah penanganan yang efektif.",
|
||||||
|
"url": "https://kesmas.kemkes.go.id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"title": "Pemahaman Orang Tua: Kunci Mencegah Stunting",
|
||||||
|
"category": "Pola Asuh",
|
||||||
|
"readTime": "4 min",
|
||||||
|
"source": "Antara News",
|
||||||
|
"description": "Menkes menekankan bahwa pemahaman orang tua mengenai nutrisi adalah faktor terpenting dalam mencegah gagal tumbuh pada anak.",
|
||||||
|
"url": "https://www.antaranews.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "3",
|
||||||
|
"title": "Panduan Gizi Kemenkes untuk Balita",
|
||||||
|
"category": "Gizi & Nutrisi",
|
||||||
|
"readTime": "6 min",
|
||||||
|
"source": "Kemenkes RI",
|
||||||
|
"description": "Panduan terbaru mengenai asupan gizi seimbang, pentingnya protein hewani, dan suplementasi untuk balita.",
|
||||||
|
"url": "https://kemkes.go.id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "4",
|
||||||
|
"title": "11 Program Intervensi Stunting Pemerintah",
|
||||||
|
"category": "Program Pemerintah",
|
||||||
|
"readTime": "7 min",
|
||||||
|
"source": "Kemenkes RI",
|
||||||
|
"description": "Mengenal program pemerintah mulai dari pemberian TTD bagi remaja putri hingga imunisasi lengkap untuk bayi.",
|
||||||
|
"url": "https://kemkes.go.id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "5",
|
||||||
|
"title": "Peran Ibu dalam Mencegah Stunting dan Obesitas",
|
||||||
|
"category": "Keluarga",
|
||||||
|
"readTime": "5 min",
|
||||||
|
"source": "Promkes Kemenkes",
|
||||||
|
"description": "Diskusi mendalam mengenai peran krusial ibu dalam memantau berat badan selama kehamilan dan pemberian ASI eksklusif.",
|
||||||
|
"url": "https://promkes.kemkes.go.id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "6",
|
||||||
|
"title": "Masalah Status Gizi Balita di Indonesia",
|
||||||
|
"category": "Edukasi Dasar",
|
||||||
|
"readTime": "6 min",
|
||||||
|
"source": "Kemenkes RI",
|
||||||
|
"description": "Ulasan mengenai faktor langsung dan tidak langsung yang mempengaruhi status gizi balita di masa emas pertumbuhan.",
|
||||||
|
"url": "https://kemkes.go.id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "7",
|
||||||
|
"title": "Pentingnya Gizi Seimbang untuk Pertumbuhan Balita",
|
||||||
|
"category": "Gizi & Nutrisi",
|
||||||
|
"readTime": "4 min",
|
||||||
|
"source": "Poltekkes Makassar",
|
||||||
|
"description": "Mengapa gizi seimbang di awal kehidupan menjadi fondasi kesehatan, sistem imun, dan kecerdasan anak di masa depan.",
|
||||||
|
"url": "https://poltekkes-mks.ac.id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "8",
|
||||||
|
"title": "Ayo ke Posyandu: Deteksi Dini Stunting",
|
||||||
|
"category": "Layanan Kesehatan",
|
||||||
|
"readTime": "3 min",
|
||||||
|
"source": "Stunting.go.id",
|
||||||
|
"description": "Manfaat rutin menimbang berat badan dan memantau perkembangan anak di Posyandu sebagai langkah deteksi dini.",
|
||||||
|
"url": "https://stunting.go.id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "9",
|
||||||
|
"title": "Cegah Stunting itu Penting: Gerakan #AksiBergizi",
|
||||||
|
"category": "Kampanye",
|
||||||
|
"readTime": "4 min",
|
||||||
|
"source": "Ayo Sehat Kemenkes",
|
||||||
|
"description": "Informasi mengenai kampanye nasional pencegahan stunting melalui gerakan Aksi Bergizi dan Posyandu Aktif.",
|
||||||
|
"url": "https://ayosehat.kemkes.go.id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "10",
|
||||||
|
"title": "Langkah Praktis Mencegah Stunting di Rumah",
|
||||||
|
"category": "Panduan Praktis",
|
||||||
|
"readTime": "5 min",
|
||||||
|
"source": "Ayo Sehat Kemenkes",
|
||||||
|
"description": "Tips harian bagi orang tua mulai dari stimulasi anak, menjaga kebersihan lingkungan, hingga pemenuhan gizi harian.",
|
||||||
|
"url": "https://ayosehat.kemkes.go.id"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { LogoutButton } from '@/components/logout-button'
|
||||||
|
import { ArrowLeft, BookOpen, Clock, Tag, ExternalLink, Search } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import articles from './articles.json'
|
||||||
|
|
||||||
|
export default async function ArtikelStuntingPage() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const sessionCookie = cookieStore.get('user_session')
|
||||||
|
if (!sessionCookie) redirect('/')
|
||||||
|
|
||||||
|
const session = JSON.parse(sessionCookie.value)
|
||||||
|
if (session.role !== 'user') redirect('/dashboard')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex justify-between items-center px-8 py-6 border-b border-gray-100">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/user-dashboard" className="group flex items-center gap-2 text-sm font-bold hover:text-gray-600 transition-colors">
|
||||||
|
<div className="p-2 rounded-full border border-black flex items-center justify-center group-hover:bg-black group-hover:text-white transition-all">
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<span className="hidden md:block">Kembali</span>
|
||||||
|
</Link>
|
||||||
|
<div className="h-8 w-px bg-gray-200 mx-2 hidden md:block"></div>
|
||||||
|
<div className="flex flex-col justify-center">
|
||||||
|
<h1 className="text-xl font-bold leading-none text-purple-600">Edukasi Stunting</h1>
|
||||||
|
<p className="text-[10px] text-gray-500 tracking-widest uppercase mt-0.5">INFORMASI TERPERCAYA</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LogoutButton />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="p-8 max-w-6xl mx-auto flex-1 w-full flex flex-col gap-10">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<div className="relative bg-black rounded-[40px] p-10 md:p-16 overflow-hidden text-white shadow-[20px_20px_0px_0px_rgba(147,51,234,0.1)]">
|
||||||
|
<div className="relative z-10 max-w-2xl">
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 rounded-full text-[10px] font-black uppercase tracking-widest mb-6 border border-purple-400">
|
||||||
|
Pencegahan Stunting
|
||||||
|
</div>
|
||||||
|
<h2 className="text-4xl md:text-5xl font-black mb-6 leading-tight uppercase">
|
||||||
|
Cegah Stunting <br /> <span className="text-purple-400">Mulai dari Sekarang</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-400 text-lg font-medium leading-relaxed mb-8">
|
||||||
|
Kumpulan informasi dan panduan teruji dari para ahli untuk membantu Bapak/Ibu memastikan tumbuh kembang optimal sang buah hati.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-white/5 border border-white/10 rounded-2xl">
|
||||||
|
<BookOpen className="w-5 h-5 text-purple-400" />
|
||||||
|
<span className="text-sm font-bold">{articles.length} Artikel Terpilih</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 bg-white/5 border border-white/10 rounded-2xl">
|
||||||
|
<Clock className="w-5 h-5 text-purple-400" />
|
||||||
|
<span className="text-sm font-bold">Update Berkala</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Background Elements */}
|
||||||
|
<div className="absolute top-0 right-0 w-1/2 h-full hidden lg:block opacity-20">
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-purple-600 rounded-full blur-[120px]"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Article Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-20">
|
||||||
|
{articles.map((article) => (
|
||||||
|
<div key={article.id} className="group bg-white rounded-3xl border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] transition-all overflow-hidden flex flex-col h-full">
|
||||||
|
<div className="p-8 flex-1 flex flex-col gap-6">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="px-3 py-1 bg-purple-50 text-purple-600 border border-purple-100 rounded-full text-[10px] font-black uppercase tracking-widest flex items-center gap-1.5">
|
||||||
|
<Tag className="w-3 h-3" />
|
||||||
|
{article.category}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-bold text-gray-400 flex items-center gap-1.5 uppercase tracking-tighter">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{article.readTime} Baca
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<h3 className="text-2xl font-black text-gray-900 group-hover:text-purple-600 transition-colors uppercase leading-tight">
|
||||||
|
{article.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 font-semibold text-sm leading-relaxed line-clamp-3">
|
||||||
|
{article.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-8 py-6 bg-gray-50 border-t-2 border-black flex justify-between items-center group-hover:bg-purple-600 transition-colors">
|
||||||
|
<span className="text-[10px] font-black text-gray-400 group-hover:text-white/80 uppercase tracking-widest">SUMBER: {article.source}</span>
|
||||||
|
<a
|
||||||
|
href={article.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-black text-white rounded-xl text-xs font-black shadow-[3px_3px_0px_0px_rgba(147,51,234,0.5)] hover:shadow-none transition-all group-hover:bg-white group-hover:text-purple-600 group-hover:shadow-none"
|
||||||
|
>
|
||||||
|
BACA LENGKAP
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { MapPin, Phone, Eye, Building2, Star } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
interface Posyandu {
|
||||||
|
id: string
|
||||||
|
nama_posyandu: string
|
||||||
|
alamat: string
|
||||||
|
kontak: string | null
|
||||||
|
latitude: number | null
|
||||||
|
longitude: number | null
|
||||||
|
link_google_maps: string | null
|
||||||
|
petugas?: {
|
||||||
|
nama_petugas: string
|
||||||
|
nomor_hp: string | null
|
||||||
|
jabatan: string | null
|
||||||
|
}[]
|
||||||
|
reviews?: {
|
||||||
|
rating: number
|
||||||
|
ulasan: string
|
||||||
|
nama_pengulas: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: Posyandu
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PosyanduCard({ data }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="group bg-white rounded-3xl border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] transition-all overflow-hidden flex flex-col h-full">
|
||||||
|
<div className="p-6 bg-purple-600 border-b-2 border-black flex justify-between items-center">
|
||||||
|
<div className="p-2 bg-white rounded-xl border-2 border-black">
|
||||||
|
<Building2 className="w-6 h-6 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
{data.reviews && data.reviews.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-1 bg-white border-2 border-black rounded-full shadow-[2px_2px_0px_0px_rgba(0,0,0,1)]">
|
||||||
|
<Star className="w-3.5 h-3.5 text-yellow-400 fill-current" />
|
||||||
|
<span className="text-xs font-black">
|
||||||
|
{(data.reviews.reduce((acc, r) => acc + r.rating, 0) / data.reviews.length).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-400 font-bold">({data.reviews.length})</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 flex-1 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h3 className="text-xl font-black text-gray-900 group-hover:text-purple-600 transition-colors uppercase">
|
||||||
|
{data.nama_posyandu}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-1.5 text-gray-500 text-xs font-semibold">
|
||||||
|
<MapPin className="w-3 h-3 flex-shrink-0" />
|
||||||
|
<span className="line-clamp-1">{data.alamat}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mt-auto">
|
||||||
|
<div className="px-3 py-1 bg-gray-50 border border-gray-100 rounded-lg text-[10px] font-bold text-gray-500 flex items-center gap-1.5">
|
||||||
|
<span className="text-purple-600">{data.petugas?.length || 0}</span> Petugas
|
||||||
|
</div>
|
||||||
|
{data.kontak && (
|
||||||
|
<div className="px-3 py-1 bg-gray-50 border border-gray-100 rounded-lg text-[10px] font-bold text-gray-500 flex items-center gap-1.5">
|
||||||
|
<Phone className="w-3 h-3 text-emerald-500" />
|
||||||
|
{data.kontak}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 pt-0">
|
||||||
|
<Link
|
||||||
|
href={`/user-dashboard/lokasi-posyandu/${data.id}`}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-black text-white font-black rounded-2xl hover:bg-gray-900 transition-all shadow-[4px_4px_0px_0px_rgba(147,51,234,0.5)] hover:shadow-none hover:translate-x-[1px] hover:translate-y-[1px]"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
LIHAT DETAIL & REVIEW
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { Search, Building2 } from 'lucide-react'
|
||||||
|
import PosyanduCard from './PosyanduCard'
|
||||||
|
|
||||||
|
interface Posyandu {
|
||||||
|
id: string
|
||||||
|
nama_posyandu: string
|
||||||
|
alamat: string
|
||||||
|
kontak: string | null
|
||||||
|
latitude: number | null
|
||||||
|
longitude: number | null
|
||||||
|
link_google_maps: string | null
|
||||||
|
petugas?: {
|
||||||
|
nama_petugas: string
|
||||||
|
nomor_hp: string | null
|
||||||
|
jabatan: string | null
|
||||||
|
}[]
|
||||||
|
reviews?: {
|
||||||
|
rating: number
|
||||||
|
ulasan: string
|
||||||
|
nama_pengulas: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialData: Posyandu[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PosyanduList({ initialData }: Props) {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
return initialData.filter(p =>
|
||||||
|
p.nama_posyandu.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
p.alamat.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
)
|
||||||
|
}, [initialData, searchTerm])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="relative w-full md:w-96">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Cari nama posyandu atau alamat..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-gray-50 border-2 border-gray-100 focus:border-black rounded-xl text-sm font-semibold outline-none transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid of Cards */}
|
||||||
|
{filteredData.length === 0 ? (
|
||||||
|
<div className="py-20 text-center flex flex-col items-center gap-2 text-gray-400">
|
||||||
|
<Building2 className="w-12 h-12 opacity-20" />
|
||||||
|
<p className="font-bold">Tidak ada posyandu ditemukan</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{filteredData.map((p) => (
|
||||||
|
<PosyanduCard key={p.id} data={p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { MapPin, Phone, User, ExternalLink, Map as MapIcon, Star, Send, Loader2, Building2 } from 'lucide-react'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import { submitReview } from '../action-review'
|
||||||
|
import { showSwal } from '@/lib/swal'
|
||||||
|
|
||||||
|
interface Posyandu {
|
||||||
|
id: string
|
||||||
|
nama_posyandu: string
|
||||||
|
alamat: string
|
||||||
|
kontak: string | null
|
||||||
|
latitude: number | null
|
||||||
|
longitude: number | null
|
||||||
|
link_google_maps: string | null
|
||||||
|
petugas?: {
|
||||||
|
nama_petugas: string
|
||||||
|
nomor_hp: string | null
|
||||||
|
jabatan: string | null
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Review {
|
||||||
|
id: string
|
||||||
|
rating: number
|
||||||
|
ulasan: string
|
||||||
|
nama_pengulas: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: Posyandu
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PosyanduDetailClient({ data, userId }: Props) {
|
||||||
|
const [reviews, setReviews] = useState<Review[]>([])
|
||||||
|
const [isLoadingReviews, setIsLoadingReviews] = useState(true)
|
||||||
|
const [rating, setRating] = useState(5)
|
||||||
|
const [comment, setComment] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchReviews()
|
||||||
|
}, [data.id])
|
||||||
|
|
||||||
|
async function fetchReviews() {
|
||||||
|
setIsLoadingReviews(true)
|
||||||
|
const { data: reviewData, error } = await supabase
|
||||||
|
.from('ulasan_posyandu')
|
||||||
|
.select('*')
|
||||||
|
.eq('posyandu_id', data.id)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
|
if (!error && reviewData) {
|
||||||
|
setReviews(reviewData as any)
|
||||||
|
}
|
||||||
|
setIsLoadingReviews(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!comment.trim()) {
|
||||||
|
showSwal.error('Ops!', 'Komentar tidak boleh kosong.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
const result = await submitReview({
|
||||||
|
posyandu_id: data.id,
|
||||||
|
nama_pengulas: 'Orang Tua', // Or fetch the actual name if available in session
|
||||||
|
rating,
|
||||||
|
comment: comment
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showSwal.success('Berhasil!', 'Review Anda telah terkirim.')
|
||||||
|
setComment('')
|
||||||
|
setRating(5)
|
||||||
|
fetchReviews()
|
||||||
|
} else {
|
||||||
|
showSwal.error('Gagal!', result.error || 'Terjadi kesalahan saat mengirim ulasan.')
|
||||||
|
}
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapSrc = `https://maps.google.com/maps?q=${encodeURIComponent(data.alamat)}&z=15&output=embed`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{/* Left Column - Information */}
|
||||||
|
<div className="lg:col-span-2 flex flex-col gap-8">
|
||||||
|
{/* Basic Info Card */}
|
||||||
|
<div className="bg-white rounded-3xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] overflow-hidden">
|
||||||
|
<div className="p-8 bg-purple-600 border-b-2 border-black">
|
||||||
|
<h3 className="text-2xl font-black text-white flex items-center gap-3 uppercase">
|
||||||
|
<Building2 className="w-7 h-7" />
|
||||||
|
Informasi Utama
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-8 flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-[10px] font-black uppercase text-gray-400 tracking-widest flex items-center gap-2">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
Alamat Posyandu
|
||||||
|
</span>
|
||||||
|
<p className="text-lg font-bold leading-relaxed">{data.alamat}</p>
|
||||||
|
</div>
|
||||||
|
{data.kontak && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-[10px] font-black uppercase text-gray-400 tracking-widest flex items-center gap-2">
|
||||||
|
<Phone className="w-3 h-3" />
|
||||||
|
Kontak Layanan
|
||||||
|
</span>
|
||||||
|
<p className="text-lg font-bold">{data.kontak}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Petugas Card */}
|
||||||
|
<div className="bg-white rounded-3xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(147,51,234,0.3)] overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<h3 className="text-base font-black flex items-center gap-3">
|
||||||
|
<User className="w-5 h-5 text-purple-600" />
|
||||||
|
Petugas Bertugas
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs font-bold text-purple-600 bg-purple-50 px-3 py-1 rounded-full border border-purple-100 uppercase tracking-widest">
|
||||||
|
{data.petugas?.length || 0} Orang
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{data.petugas && data.petugas.length > 0 ? (
|
||||||
|
data.petugas.map((pt: any, i) => (
|
||||||
|
<div key={i} className="p-6 flex items-center justify-between hover:bg-gray-50/50 transition-all">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-purple-100 text-purple-600 flex items-center justify-center font-black text-xl">
|
||||||
|
{pt.nama_petugas?.[0]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-gray-900">{pt.nama_petugas}</p>
|
||||||
|
<p className="text-xs text-gray-500 font-semibold uppercase tracking-wider">{pt.jabatan || 'Anggota'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{pt.nomor_hp && (
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-50 text-emerald-600 rounded-lg text-xs font-bold">
|
||||||
|
<Phone className="w-3 h-3" />
|
||||||
|
{pt.nomor_hp}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="p-12 text-center text-gray-400 italic text-sm font-semibold">
|
||||||
|
Belum ada data petugas terdaftar di posyandu ini.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reviews List */}
|
||||||
|
<div className="flex flex-col gap-6 mt-4">
|
||||||
|
<h3 className="font-black text-xl flex items-center justify-between">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Star className="w-6 h-6 text-yellow-500" />
|
||||||
|
Ulasan Pengguna
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold bg-gray-100 px-3 py-1 rounded-full text-gray-500">{reviews.length} REVIEW</span>
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{isLoadingReviews ? (
|
||||||
|
<div className="flex flex-col items-center py-12 gap-2 text-gray-300">
|
||||||
|
<Loader2 className="w-10 h-10 animate-spin" />
|
||||||
|
<p className="text-sm font-bold">Memuat ulasan masyarakat...</p>
|
||||||
|
</div>
|
||||||
|
) : reviews.length > 0 ? (
|
||||||
|
reviews.map((rev) => (
|
||||||
|
<div key={rev.id} className="p-6 bg-white border-2 border-black rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,0.05)] flex flex-col gap-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center text-[10px] font-black">
|
||||||
|
{rev.nama_pengulas?.[0] || 'A'}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-black text-gray-900">{rev.nama_pengulas || 'Orang Tua'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Star key={i} className={`w-3 h-3 ${i < rev.rating ? 'text-yellow-400 fill-current' : 'text-gray-200'}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 font-semibold italic">"{rev.ulasan}"</p>
|
||||||
|
<p className="text-[10px] text-gray-400 font-black uppercase tracking-widest">{new Date(rev.created_at).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })}</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="py-20 text-center text-gray-300 flex flex-col items-center gap-4 border-4 border-dashed border-gray-100 rounded-[32px]">
|
||||||
|
<Star className="w-12 h-12 opacity-10" />
|
||||||
|
<p className="font-bold italic">Belum ada ulasan untuk lokasi ini.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Map & Review Form */}
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
{/* Map Card */}
|
||||||
|
<div className="bg-white rounded-3xl border-2 border-black shadow-[8px_8px_0px_0px_rgba(59,130,246,0.2)] overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-gray-100 flex items-center justify-between bg-blue-50/30">
|
||||||
|
<h3 className="text-base font-black flex items-center gap-3">
|
||||||
|
<MapIcon className="w-5 h-5 text-blue-600" />
|
||||||
|
Lokasi Maps
|
||||||
|
</h3>
|
||||||
|
{data.link_google_maps && (
|
||||||
|
<a href={data.link_google_maps} target="_blank" rel="noopener noreferrer" className="p-2 bg-white text-blue-600 border border-blue-200 rounded-lg hover:shadow-md transition-all">
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="aspect-square w-full bg-gray-100 relative">
|
||||||
|
<iframe width="100%" height="100%" style={{ border: 0 }} loading="lazy" allowFullScreen src={mapSrc}></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Review Form Card */}
|
||||||
|
<div className="bg-black text-white rounded-[32px] p-8 flex flex-col gap-6 shadow-[12px_12px_0px_0px_rgba(147,51,234,0.4)] relative overflow-hidden">
|
||||||
|
<div className="z-10">
|
||||||
|
<h3 className="text-xl font-black text-purple-400 uppercase tracking-tight mb-2">Berikan Ulasan</h3>
|
||||||
|
<p className="text-xs text-gray-400 font-semibold mb-6">Ceritakan pengalaman Bapak/Ibu mengenai pelayanan di posyandu ini.</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<button
|
||||||
|
key={star}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRating(star)}
|
||||||
|
className={`transition-all duration-200 ${rating >= star ? 'text-yellow-400 scale-125 drop-shadow-[0_0_8px_rgba(250,204,21,0.5)]' : 'text-gray-700 hover:text-gray-500'}`}
|
||||||
|
>
|
||||||
|
<Star className="w-8 h-8 fill-current" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
placeholder="Tuliskan ulasan anda disini..."
|
||||||
|
className="w-full bg-[#111] border-2 border-gray-800 rounded-2xl p-4 text-sm font-semibold outline-none focus:border-purple-500 transition-all min-h-[120px] resize-none"
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full bg-purple-600 hover:bg-purple-700 text-white font-black py-4 rounded-2xl flex items-center justify-center gap-3 transition-all active:scale-95 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Loader2 className="w-5 h-5 animate-spin" /> : <Send className="w-5 h-5" />}
|
||||||
|
KIRIM ULASAN SEKARANG
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/* Background decoration */}
|
||||||
|
<div className="absolute -right-10 -bottom-10 w-40 h-40 bg-purple-600/10 rounded-full blur-3xl"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import { LogoutButton } from '@/components/logout-button'
|
||||||
|
import { ArrowLeft, Building2, MapPin, Phone, User, ExternalLink, Map as MapIcon } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import PosyanduDetailClient from "./PosyanduDetailClient"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PosyanduDetailPage({ params }: Props) {
|
||||||
|
const { id } = await params
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const sessionCookie = cookieStore.get('user_session')
|
||||||
|
if (!sessionCookie) redirect('/')
|
||||||
|
|
||||||
|
const session = JSON.parse(sessionCookie.value)
|
||||||
|
if (session.role !== 'user') redirect('/dashboard')
|
||||||
|
|
||||||
|
const { data: posyandu, error } = await supabase
|
||||||
|
.from('detail_posyandu')
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
petugas:petugas_posyandu_lokal(*)
|
||||||
|
`)
|
||||||
|
.eq('id', id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error || !posyandu) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center text-red-500 font-semibold bg-white p-8">
|
||||||
|
<div className="bg-red-50 p-6 rounded-2xl border-2 border-red-200 text-center max-w-md">
|
||||||
|
<p className="font-black text-xl mb-2">Data Tidak Ditemukan</p>
|
||||||
|
<Link href="/user-dashboard/lokasi-posyandu" className="inline-block mt-4 text-xs font-bold underline">Kembali ke Daftar Posyandu</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex justify-between items-center px-8 py-6 border-b border-gray-100">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/user-dashboard/lokasi-posyandu" className="group flex items-center gap-2 text-sm font-bold hover:text-gray-600 transition-colors">
|
||||||
|
<div className="p-2 rounded-full border border-black flex items-center justify-center group-hover:bg-black group-hover:text-white transition-all">
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<span className="hidden md:block">Kembali</span>
|
||||||
|
</Link>
|
||||||
|
<div className="h-8 w-px bg-gray-200 mx-2 hidden md:block"></div>
|
||||||
|
<div className="flex flex-col justify-center">
|
||||||
|
<h1 className="text-xl font-bold leading-none text-purple-600">{posyandu.nama_posyandu}</h1>
|
||||||
|
<p className="text-[10px] text-gray-500 tracking-widest uppercase mt-0.5">DETAIL LOKASI & ULASAN</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LogoutButton />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="p-8 max-w-6xl mx-auto w-full">
|
||||||
|
<PosyanduDetailClient data={posyandu} userId={session.id} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
'use server'
|
||||||
|
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
|
||||||
|
export async function getPosyanduWithReviews() {
|
||||||
|
const { data: posyandu, error } = await supabase
|
||||||
|
.from('detail_posyandu')
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
petugas:petugas_posyandu_lokal(*),
|
||||||
|
reviews:ulasan_posyandu(
|
||||||
|
*,
|
||||||
|
nama_pengulas
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.order('nama_posyandu', { ascending: true })
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error fetching posyandu:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return posyandu
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitReview(formData: {
|
||||||
|
posyandu_id: string
|
||||||
|
nama_pengulas: string
|
||||||
|
rating: number
|
||||||
|
comment: string
|
||||||
|
}) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('ulasan_posyandu')
|
||||||
|
.insert([
|
||||||
|
{
|
||||||
|
posyandu_id: formData.posyandu_id,
|
||||||
|
rating: formData.rating,
|
||||||
|
ulasan: formData.comment,
|
||||||
|
nama_pengulas: formData.nama_pengulas, // Add this field to the formData or handle it separately
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error submitting review:', error)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/user-dashboard/lokasi-posyandu')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import { LogoutButton } from '@/components/logout-button'
|
||||||
|
import { ArrowLeft, Building2, Search } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import PosyanduList from "./PosyanduList"
|
||||||
|
|
||||||
|
export async function getPosyanduWithReviews() {
|
||||||
|
const { data: posyandu, error } = await supabase
|
||||||
|
.from('detail_posyandu')
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
petugas:petugas_posyandu_lokal(*),
|
||||||
|
reviews:ulasan_posyandu(
|
||||||
|
rating,
|
||||||
|
ulasan,
|
||||||
|
nama_pengulas,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.order('nama_posyandu', { ascending: true })
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error fetching posyandu:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return posyandu
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LokasiPosyanduPage() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const sessionCookie = cookieStore.get('user_session')
|
||||||
|
if (!sessionCookie) redirect('/')
|
||||||
|
|
||||||
|
const session = JSON.parse(sessionCookie.value)
|
||||||
|
if (session.role !== 'user') redirect('/dashboard')
|
||||||
|
|
||||||
|
// Fetch all posyandu data using server action
|
||||||
|
const posyanduData = await getPosyanduWithReviews()
|
||||||
|
|
||||||
|
if (!posyanduData) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center text-red-500 font-semibold bg-white p-8">
|
||||||
|
<div className="bg-red-50 p-6 rounded-2xl border-2 border-red-200 text-center max-w-md">
|
||||||
|
<p className="font-black text-xl mb-2">Gagal Memuat Data</p>
|
||||||
|
<p className="text-sm opacity-80">Terjadi kesalahan pada server.</p>
|
||||||
|
<Link href="/user-dashboard" className="inline-block mt-4 text-xs font-bold underline">Kembali ke Dashboard</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white font-sans text-black flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="flex justify-between items-center px-8 py-6 border-b border-gray-100">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/user-dashboard" className="group flex items-center gap-2 text-sm font-bold hover:text-gray-600 transition-colors">
|
||||||
|
<div className="p-2 rounded-full border border-black flex items-center justify-center group-hover:bg-black group-hover:text-white transition-all">
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<span className="hidden md:block">Kembali</span>
|
||||||
|
</Link>
|
||||||
|
<div className="h-8 w-px bg-gray-200 mx-2 hidden md:block"></div>
|
||||||
|
<div className="flex flex-col justify-center">
|
||||||
|
<h1 className="text-xl font-bold leading-none text-purple-600">Lokasi Posyandu</h1>
|
||||||
|
<p className="text-[10px] text-gray-500 tracking-widest uppercase mt-0.5">LAYANAN KESEHATAN</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LogoutButton />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="p-8 max-w-6xl mx-auto flex-1 w-full flex flex-col gap-8">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<div className="flex flex-col md:flex-row items-center gap-8 bg-purple-50 p-8 rounded-3xl border-2 border-purple-100 relative overflow-hidden">
|
||||||
|
<div className="flex-1 z-10">
|
||||||
|
<h2 className="text-3xl font-black mb-2 text-purple-900">Temukan Layanan Terdekat</h2>
|
||||||
|
<p className="text-purple-600/80 max-w-lg leading-relaxed font-semibold">
|
||||||
|
Cari lokasi posyandu di wilayah Anda, cek petugas yang bertugas, dan berikan ulasan untuk meningkatkan kualitas layanan.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-20 h-20 bg-purple-600 rounded-2xl flex items-center justify-center text-white shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] rotate-3 z-10">
|
||||||
|
<Building2 className="w-10 h-10" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute -right-10 -bottom-10 w-48 h-48 bg-purple-200/50 rounded-full blur-3xl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Client Component for Search and Cards */}
|
||||||
|
<PosyanduList initialData={(posyanduData as any) ?? []} />
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
-- Query Supabase untuk Tabel Ulasan (Reviews) Posyandu
|
||||||
|
|
||||||
|
-- 0. Aktifkan Ekstensi UUID (Jika belum aktif)
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- 1. Buat Tabel posyandu_reviews
|
||||||
|
CREATE TABLE IF NOT EXISTS posyandu_reviews (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
posyandu_id UUID REFERENCES detail_posyandu(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES akun_balita(id) ON DELETE CASCADE,
|
||||||
|
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
|
||||||
|
comment TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. Aktifkan Row Level Security (RLS)
|
||||||
|
ALTER TABLE posyandu_reviews ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- 3. Kebijakan RLS (Policies)
|
||||||
|
|
||||||
|
-- Kebijakan: Semua orang dapat melihat ulasan
|
||||||
|
CREATE POLICY "Anyone can view reviews"
|
||||||
|
ON posyandu_reviews FOR SELECT
|
||||||
|
USING (true);
|
||||||
|
|
||||||
|
-- Kebijakan: User terautentikasi dapat mengirim ulasan (untuk akun_balita)
|
||||||
|
CREATE POLICY "Authenticated users can insert reviews"
|
||||||
|
ON posyandu_reviews FOR INSERT
|
||||||
|
TO authenticated
|
||||||
|
WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- Kebijakan: User dapat menghapus ulasan mereka sendiri
|
||||||
|
CREATE POLICY "Users can delete their own reviews"
|
||||||
|
ON posyandu_reviews FOR DELETE
|
||||||
|
TO authenticated
|
||||||
|
USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- 4. Indeks untuk Performa
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posyandu_reviews_posyandu_id ON posyandu_reviews(posyandu_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_posyandu_reviews_user_id ON posyandu_reviews(user_id);
|
||||||
|
|
@ -201,7 +201,7 @@ export default async function UserDashboardPage() {
|
||||||
title="Lokasi Posyandu"
|
title="Lokasi Posyandu"
|
||||||
description="Temukan lokasi posyandu terdekat dan jadwal kegiatan."
|
description="Temukan lokasi posyandu terdekat dan jadwal kegiatan."
|
||||||
icon={MapPin}
|
icon={MapPin}
|
||||||
href="#"
|
href="/user-dashboard/lokasi-posyandu"
|
||||||
color="orange"
|
color="orange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -210,7 +210,7 @@ export default async function UserDashboardPage() {
|
||||||
title="Artikel Stunting"
|
title="Artikel Stunting"
|
||||||
description="Baca artikel dan informasi edukasi pencegahan stunting."
|
description="Baca artikel dan informasi edukasi pencegahan stunting."
|
||||||
icon={ClipboardList}
|
icon={ClipboardList}
|
||||||
href="#"
|
href="/user-dashboard/artikel"
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,35 @@
|
||||||
|
|
||||||
|
import { createClient } from '@supabase/supabase-js'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
dotenv.config({ path: '.env.local' })
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
|
||||||
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
|
|
||||||
|
const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
||||||
|
|
||||||
|
async function debugQuery() {
|
||||||
|
console.log('Testing query...')
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('detail_posyandu')
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
petugas:petugas_posyandu_lokal(*),
|
||||||
|
reviews:posyandu_reviews(
|
||||||
|
rating,
|
||||||
|
comment,
|
||||||
|
created_at,
|
||||||
|
user:akun_balita(nama_orang_tua)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Full Error Object:', error)
|
||||||
|
console.error('Error JSON:', JSON.stringify(error, null, 2))
|
||||||
|
} else {
|
||||||
|
console.log('Data fetched successfully:', JSON.stringify(data, null, 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debugQuery()
|
||||||
Loading…
Reference in New Issue