diff --git a/app/(admin)/dashboard/boards/competitions/page.tsx b/app/(admin)/dashboard/boards/competitions/page.tsx
new file mode 100644
index 0000000..cd15f1f
--- /dev/null
+++ b/app/(admin)/dashboard/boards/competitions/page.tsx
@@ -0,0 +1,88 @@
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { getActiveCompetition, getCompetitionCategories, getCompetitionEntries } from '@/lib/server/competition-public'
+
+export default async function CompetitionsPage() {
+ const competition = await getActiveCompetition()
+
+ if (!competition) {
+ return (
+
+ Belum ada kompetisi aktif yang bisa dikelola.
+
+ )
+ }
+
+ const [categories, entriesResult] = await Promise.all([
+ getCompetitionCategories(competition.id),
+ getCompetitionEntries({
+ competitionId: competition.id,
+ sort: 'top',
+ }),
+ ])
+
+ const entries = entriesResult?.entries ?? []
+
+ return (
+
+
+
+
+ Kompetisi aktif
+
+
+ {competition.title}
+ {competition.tagline}
+
+
+
+
+
+ Total entry publik
+
+
+ {entries.length}
+
+
+
+
+
+ Kategori aktif
+
+
+ {categories.length}
+
+
+
+
+
+
+ Top entries sementara
+
+
+ {entries.length === 0 ? (
+ Belum ada entry yang tayang.
+ ) : (
+
+ {entries.slice(0, 10).map((entry) => (
+
+
+
{entry.title}
+
+ {entry.author?.displayName || 'Peserta'} • {entry.category?.label || 'Umum'}
+
+
+
+ {entry.voteCount} vote • {entry.commentCount} komentar
+
+
+ ))}
+
+ )}
+
+
+
+ )
+}
diff --git a/app/(admin)/dashboard/page.tsx b/app/(admin)/dashboard/page.tsx
index c6734bc..74cf565 100644
--- a/app/(admin)/dashboard/page.tsx
+++ b/app/(admin)/dashboard/page.tsx
@@ -7,6 +7,7 @@ import {
IconNews,
IconNotification,
IconSettings2,
+ IconTrophy,
IconUsers,
} from '@tabler/icons-react'
import { Header } from '@/components/admin-panel/header'
@@ -14,6 +15,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import Analytics from './boards/analytics'
import BlogPage from './boards/blog/page'
import CommentsPage from './boards/comments/page'
+import CompetitionsPage from './boards/competitions/page'
import EventsApproval from './boards/events-approval/page'
import Overview from './boards/overview'
import ProjectsPage from './boards/projects/page'
@@ -112,6 +114,13 @@ export default async function Dashboard1Page({ searchParams }: { searchParams: P
Users
+
+
+ Competitions
+
+
+
+
= new Date(startsAt) && now < new Date(endsAt)
+}
+
+export default async function CompetitionEntryPage({ params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params
+ const currentUser = await getCurrentUser()
+ const entry = await getCompetitionEntryBySlug(slug, currentUser?.id)
+
+ if (!entry) {
+ notFound()
+ }
+
+ const { comments: initialComments } = await getComments('competition', entry.id)
+ const user = currentUser
+ ? {
+ name: currentUser.name,
+ email: currentUser.email,
+ avatar: currentUser.avatar,
+ username: currentUser.username,
+ role: currentUser.role,
+ }
+ : null
+
+ const competitionOpen = isCompetitionOpen(entry.competition.startsAt, entry.competition.endsAt)
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {entry.category?.label || 'Umum'}
+
+ {new Date(entry.submittedAt).toLocaleDateString('id-ID')}
+
+
+ {initialComments.length} komentar
+
+
+
+
+
{entry.title}
+
{entry.tagline}
+
+
+ {entry.author ? (
+
+
+
+
+
@{entry.author.username}
+
+
+ ) : null}
+
+
+
+
+
+ Tentang entry ini
+ {entry.description}
+
+
+
+ Proses vibe coding
+
+ {entry.processSummary}
+
+
+
+
+
+ AI tools
+ {entry.aiToolsUsed.join(', ')}
+
+
+
+ Tech stack
+ {entry.techStacks.join(', ')}
+
+
+
+ {entry.galleryUrls.length > 0 ? (
+
+ Galeri
+
+ {entry.galleryUrls.map((url) => (
+
+
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/competition/list/competition-list-client.tsx b/app/competition/list/competition-list-client.tsx
new file mode 100644
index 0000000..c402858
--- /dev/null
+++ b/app/competition/list/competition-list-client.tsx
@@ -0,0 +1,45 @@
+'use client'
+
+import { CompetitionEntryFeed } from '@/components/competition/competition-entry-feed'
+import { CompetitionSortControls } from '@/components/competition/competition-sort-controls'
+import type { CompetitionEntrySummary, CompetitionSort } from '@/types/competition'
+
+interface CompetitionListClientProps {
+ entries: CompetitionEntrySummary[]
+ sort: CompetitionSort
+ isLoggedIn: boolean
+ currentUserId?: string
+ voteState: Record
+ isCompetitionOpen: boolean
+}
+
+export function CompetitionListClient({
+ entries,
+ sort,
+ isLoggedIn,
+ currentUserId,
+ voteState,
+ isCompetitionOpen,
+}: CompetitionListClientProps) {
+ return (
+
+
+
+
Feed entry publik
+
+ Urutan “Teratas” memakai vote_count desc lalu submitted_at asc.
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/competition/list/page.tsx b/app/competition/list/page.tsx
new file mode 100644
index 0000000..4bba3d4
--- /dev/null
+++ b/app/competition/list/page.tsx
@@ -0,0 +1,96 @@
+import { notFound } from 'next/navigation'
+import { Footer } from '@/components/ui/footer'
+import { Navbar } from '@/components/ui/navbar'
+import { getCurrentUser } from '@/lib/server/auth'
+import {
+ getActiveCompetition,
+ getCompetitionEntries,
+ getCompetitionEntryVoteState,
+} from '@/lib/server/competition-public'
+import type { CompetitionSort } from '@/types/competition'
+import { CompetitionListClient } from './competition-list-client'
+
+type SearchParams = Promise<{ sort?: string }>
+
+function normalizeSort(sort?: string): CompetitionSort {
+ return sort === 'oldest' || sort === 'top' ? sort : 'newest'
+}
+
+function isCompetitionOpen(startsAt: string, endsAt: string) {
+ const now = new Date()
+ return now >= new Date(startsAt) && now < new Date(endsAt)
+}
+
+export default async function CompetitionListPage({ searchParams }: { searchParams: SearchParams }) {
+ const [{ sort }, competition, currentUser] = await Promise.all([
+ searchParams,
+ getActiveCompetition(),
+ getCurrentUser(),
+ ])
+
+ if (!competition) {
+ notFound()
+ }
+
+ const normalizedSort = normalizeSort(sort)
+ const entriesResult = await getCompetitionEntries({
+ competitionId: competition.id,
+ sort: normalizedSort,
+ userId: currentUser?.id,
+ })
+
+ if (!entriesResult) {
+ notFound()
+ }
+
+ const voteState = currentUser
+ ? await getCompetitionEntryVoteState(
+ entriesResult.entries.map((entry) => entry.id),
+ currentUser.id,
+ )
+ : {}
+
+ const user = currentUser
+ ? {
+ name: currentUser.name,
+ email: currentUser.email,
+ avatar: currentUser.avatar,
+ username: currentUser.username,
+ role: currentUser.role,
+ }
+ : null
+
+ return (
+
+
+
+
+
+
+
+
+ Mini Vibeathon Entries
+
+ Jelajahi semua entry publik dari kompetisi aktif dan dukung karya favoritmu dengan vote.
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/competition/page.tsx b/app/competition/page.tsx
new file mode 100644
index 0000000..5028f5f
--- /dev/null
+++ b/app/competition/page.tsx
@@ -0,0 +1,90 @@
+import { notFound } from 'next/navigation'
+import { CompetitionFaq } from '@/components/competition/competition-faq'
+import { CompetitionHero } from '@/components/competition/competition-hero'
+import { CompetitionRules } from '@/components/competition/competition-rules'
+import { CompetitionTimeline } from '@/components/competition/competition-timeline'
+import { Footer } from '@/components/ui/footer'
+import { Navbar } from '@/components/ui/navbar'
+import { getCurrentUser } from '@/lib/server/auth'
+import { getActiveCompetition, getCompetitionCategories } from '@/lib/server/competition-public'
+
+function isCompetitionOpen(startsAt: string, endsAt: string) {
+ const now = new Date()
+ return now >= new Date(startsAt) && now < new Date(endsAt)
+}
+
+export default async function CompetitionPage() {
+ const [competition, currentUser] = await Promise.all([getActiveCompetition(), getCurrentUser()])
+
+ if (!competition) {
+ notFound()
+ }
+
+ const categories = await getCompetitionCategories(competition.id)
+ const user = currentUser
+ ? {
+ name: currentUser.name,
+ email: currentUser.email,
+ avatar: currentUser.avatar,
+ username: currentUser.username,
+ role: currentUser.role,
+ }
+ : null
+
+ const competitionOpen = isCompetitionOpen(competition.startsAt, competition.endsAt)
+
+ return (
+
+
+
+
+
+
+
+
+
+ {categories.length > 0 ? (
+
+ Kategori kompetisi
+
+ {categories.map((category) => (
+
+ {category.label}
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/competition/submit/page.tsx b/app/competition/submit/page.tsx
new file mode 100644
index 0000000..4068af2
--- /dev/null
+++ b/app/competition/submit/page.tsx
@@ -0,0 +1,63 @@
+import { redirect } from 'next/navigation'
+import { CompetitionSubmitForm } from '@/components/competition/competition-submit-form'
+import { Card, CardContent } from '@/components/ui/card'
+import { getActiveCompetition, getCompetitionCategories } from '@/lib/server/competition-public'
+import { createClient } from '@/lib/supabase/server'
+
+function isCompetitionOpen(startsAt: string, endsAt: string) {
+ const now = new Date()
+ return now >= new Date(startsAt) && now < new Date(endsAt)
+}
+
+export default async function CompetitionSubmitPage() {
+ const supabase = await createClient()
+ const redirectTo = '/competition/submit'
+ const {
+ data: { user },
+ error,
+ } = await supabase.auth.getUser()
+
+ if (error || !user) {
+ redirect(`/user/auth?redirectTo=${redirectTo}`)
+ }
+
+ const competition = await getActiveCompetition()
+ if (!competition) {
+ redirect('/competition')
+ }
+
+ const categories = await getCompetitionCategories(competition.id)
+ const competitionOpen = isCompetitionOpen(competition.startsAt, competition.endsAt)
+
+ return (
+
+
+
+
+
+
+
Kirim entry Mini Vibeathon
+
+ Submission akan langsung tayang di feed publik dan tidak bisa diedit setelah dikirim.
+
+
+
+ {!competitionOpen ? (
+
+
+
+ Submission belum dibuka atau sudah ditutup. Pantau halaman kompetisi untuk timeline resminya.
+
+
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
diff --git a/app/sitemap.ts b/app/sitemap.ts
index 053e6d8..7957f78 100644
--- a/app/sitemap.ts
+++ b/app/sitemap.ts
@@ -6,6 +6,9 @@ export default async function sitemap(): Promise {
// Static core routes. Add dynamic routes (projects/users) here if desired.
const routes = [
'',
+ '/competition',
+ '/competition/list',
+ '/competition/submit',
'/project/list',
'/project/submit',
'/user/auth',
diff --git a/components/competition/competition-entry-card.tsx b/components/competition/competition-entry-card.tsx
new file mode 100644
index 0000000..09eb435
--- /dev/null
+++ b/components/competition/competition-entry-card.tsx
@@ -0,0 +1,105 @@
+import { MessageCircle } from 'lucide-react'
+import Image from 'next/image'
+import Link from 'next/link'
+import { CompetitionVoteButton } from '@/components/competition/competition-vote-button'
+import { Card, CardContent } from '@/components/ui/card'
+import { OptimizedAvatar } from '@/components/ui/optimized-avatar'
+import { UserDisplayName } from '@/components/ui/user-display-name'
+import type { CompetitionEntrySummary } from '@/types/competition'
+
+interface CompetitionEntryCardProps {
+ entry: CompetitionEntrySummary
+ rank?: number
+ isLoggedIn: boolean
+ currentUserId?: string
+ hasVoted: boolean
+ isCompetitionOpen: boolean
+}
+
+export function CompetitionEntryCard({
+ entry,
+ rank,
+ isLoggedIn,
+ currentUserId,
+ hasVoted,
+ isCompetitionOpen,
+}: CompetitionEntryCardProps) {
+ const isOwner = currentUserId === entry.userId
+
+ return (
+
+
+
+
+
+ #{rank ?? 0}
+
+
+
+
+
+
+
+
{entry.title}
+
+
{entry.tagline}
+
+
+
+
+ {entry.category?.label || 'Umum'}
+
+ {new Date(entry.submittedAt).toLocaleDateString('id-ID')}
+
+
+ {entry.commentCount}
+
+
+
+ {entry.author ? (
+
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/components/competition/competition-entry-feed.tsx b/components/competition/competition-entry-feed.tsx
new file mode 100644
index 0000000..e254a0b
--- /dev/null
+++ b/components/competition/competition-entry-feed.tsx
@@ -0,0 +1,42 @@
+import { CompetitionEntryCard } from '@/components/competition/competition-entry-card'
+import type { CompetitionEntrySummary } from '@/types/competition'
+
+interface CompetitionEntryFeedProps {
+ entries: CompetitionEntrySummary[]
+ isLoggedIn: boolean
+ currentUserId?: string
+ voteState: Record
+ isCompetitionOpen: boolean
+}
+
+export function CompetitionEntryFeed({
+ entries,
+ isLoggedIn,
+ currentUserId,
+ voteState,
+ isCompetitionOpen,
+}: CompetitionEntryFeedProps) {
+ if (entries.length === 0) {
+ return (
+
+ Belum ada entry yang tayang untuk kompetisi ini.
+
+ )
+ }
+
+ return (
+
+ {entries.map((entry, index) => (
+
+ ))}
+
+ )
+}
diff --git a/components/competition/competition-faq.tsx b/components/competition/competition-faq.tsx
new file mode 100644
index 0000000..346d1ce
--- /dev/null
+++ b/components/competition/competition-faq.tsx
@@ -0,0 +1,31 @@
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import type { CompetitionFaqItem } from '@/types/competition'
+
+interface CompetitionFaqProps {
+ items: CompetitionFaqItem[]
+}
+
+export function CompetitionFaq({ items }: CompetitionFaqProps) {
+ if (items.length === 0) {
+ return null
+ }
+
+ return (
+
+
+ FAQ
+
+
+ {items.map((item) => (
+
+
{item.question}
+
{item.answer}
+
+ ))}
+
+
+ )
+}
diff --git a/components/competition/competition-hero.tsx b/components/competition/competition-hero.tsx
new file mode 100644
index 0000000..cad8abb
--- /dev/null
+++ b/components/competition/competition-hero.tsx
@@ -0,0 +1,45 @@
+import Link from 'next/link'
+import { Button } from '@/components/ui/button'
+import type { Competition } from '@/types/competition'
+
+interface CompetitionHeroProps {
+ competition: Competition
+ isOpen: boolean
+}
+
+export function CompetitionHero({ competition, isOpen }: CompetitionHeroProps) {
+ return (
+
+
+
+ {competition.title}
+
+
+
{competition.tagline}
+
{competition.description}
+
+
+ Hadiah: {competition.prizeText}
+ •
+
+ {new Date(competition.startsAt).toLocaleDateString('id-ID')} -{' '}
+ {new Date(competition.endsAt).toLocaleDateString('id-ID')}
+
+ •
+ {isOpen ? 'Submission & voting sedang berjalan' : 'Kompetisi belum dibuka atau sudah selesai'}
+
+
+
+
+
+
+
+ )
+}
diff --git a/components/competition/competition-rules.tsx b/components/competition/competition-rules.tsx
new file mode 100644
index 0000000..1361762
--- /dev/null
+++ b/components/competition/competition-rules.tsx
@@ -0,0 +1,23 @@
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+
+interface CompetitionRulesProps {
+ title: string
+ content: string
+}
+
+export function CompetitionRules({ title, content }: CompetitionRulesProps) {
+ if (!content.trim()) {
+ return null
+ }
+
+ return (
+
+
+ {title}
+
+
+ {content}
+
+
+ )
+}
diff --git a/components/competition/competition-sort-controls.tsx b/components/competition/competition-sort-controls.tsx
new file mode 100644
index 0000000..8b4bbbc
--- /dev/null
+++ b/components/competition/competition-sort-controls.tsx
@@ -0,0 +1,44 @@
+'use client'
+
+import { usePathname, useRouter, useSearchParams } from 'next/navigation'
+import type { CompetitionSort } from '@/types/competition'
+
+interface CompetitionSortControlsProps {
+ value: CompetitionSort
+}
+
+const SORT_LABELS: Record = {
+ newest: 'Terbaru',
+ oldest: 'Terlama',
+ top: 'Teratas',
+}
+
+export function CompetitionSortControls({ value }: CompetitionSortControlsProps) {
+ const router = useRouter()
+ const pathname = usePathname()
+ const searchParams = useSearchParams()
+
+ return (
+
+ )
+}
diff --git a/components/competition/competition-submit-form.tsx b/components/competition/competition-submit-form.tsx
new file mode 100644
index 0000000..0965134
--- /dev/null
+++ b/components/competition/competition-submit-form.tsx
@@ -0,0 +1,580 @@
+'use client'
+
+import { Loader2, Trash2, Upload } from 'lucide-react'
+import Image from 'next/image'
+import { useRouter } from 'next/navigation'
+import { useMemo, useState } from 'react'
+import { toast } from 'sonner'
+import {
+ type CompetitionDraftSubmission,
+ CompetitionSubmitPreview,
+} from '@/components/competition/competition-submit-preview'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { submitCompetitionEntry } from '@/lib/actions/competition'
+import { UploadButton } from '@/lib/uploadthing-client'
+import type { CompetitionCategory } from '@/types/competition'
+
+interface CompetitionSubmitFormProps {
+ competitionId: string
+ categories: CompetitionCategory[]
+}
+
+interface FormState {
+ categoryId: string
+ title: string
+ tagline: string
+ description: string
+ processSummary: string
+ aiToolsInput: string
+ techStacksInput: string
+ demoUrl: string
+ repoUrl: string
+ thumbnailUrl: string
+ thumbnailKey: string
+ galleryUrls: string[]
+ galleryKeys: string[]
+ videoUrl: string
+}
+
+const INITIAL_STATE: FormState = {
+ categoryId: '',
+ title: '',
+ tagline: '',
+ description: '',
+ processSummary: '',
+ aiToolsInput: '',
+ techStacksInput: '',
+ demoUrl: '',
+ repoUrl: '',
+ thumbnailUrl: '',
+ thumbnailKey: '',
+ galleryUrls: [],
+ galleryKeys: [],
+ videoUrl: '',
+}
+
+function parseListInput(value: string): string[] {
+ return value
+ .split(',')
+ .map((item) => item.trim())
+ .filter(Boolean)
+}
+
+function buildPreviewDraft(
+ formState: FormState,
+ categories: CompetitionCategory[],
+ competitionId: string,
+): CompetitionDraftSubmission {
+ return {
+ competitionId,
+ categoryId: formState.categoryId,
+ categoryLabel: categories.find((category) => category.id === formState.categoryId)?.label || 'Belum dipilih',
+ title: formState.title.trim(),
+ tagline: formState.tagline.trim(),
+ description: formState.description.trim(),
+ processSummary: formState.processSummary.trim(),
+ aiToolsUsed: parseListInput(formState.aiToolsInput),
+ techStacks: parseListInput(formState.techStacksInput),
+ demoUrl: formState.demoUrl.trim(),
+ repoUrl: formState.repoUrl.trim(),
+ thumbnailUrl: formState.thumbnailUrl,
+ thumbnailKey: formState.thumbnailKey,
+ galleryUrls: formState.galleryUrls,
+ galleryKeys: formState.galleryKeys,
+ videoUrl: formState.videoUrl.trim(),
+ }
+}
+
+export function CompetitionSubmitForm({ competitionId, categories }: CompetitionSubmitFormProps) {
+ const router = useRouter()
+ const [formState, setFormState] = useState(INITIAL_STATE)
+ const [fieldErrors, setFieldErrors] = useState>({})
+ const [isPreviewing, setIsPreviewing] = useState(false)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ const previewDraft = useMemo(
+ () => buildPreviewDraft(formState, categories, competitionId),
+ [competitionId, categories, formState],
+ )
+
+ const validateBeforePreview = () => {
+ const errors: Record = {}
+
+ if (!previewDraft.categoryId) errors.category_id = ['Pilih kategori terlebih dahulu.']
+ if (!previewDraft.title) errors.title = ['Judul wajib diisi.']
+ if (!previewDraft.tagline) errors.tagline = ['Tagline wajib diisi.']
+ if (!previewDraft.description) errors.description = ['Deskripsi wajib diisi.']
+ if (!previewDraft.processSummary) errors.process_summary = ['Ringkasan proses wajib diisi.']
+ if (!previewDraft.demoUrl) errors.demo_url = ['Demo URL wajib diisi.']
+ if (!previewDraft.repoUrl) errors.repo_url = ['Repo URL wajib diisi.']
+ if (!previewDraft.thumbnailUrl) errors.thumbnail_url = ['Thumbnail wajib diupload.']
+ if (previewDraft.aiToolsUsed.length === 0) errors.ai_tools_used = ['Isi minimal satu AI tool.']
+ if (previewDraft.techStacks.length === 0) errors.tech_stacks = ['Isi minimal satu tech stack.']
+ if (previewDraft.galleryUrls.length === 0 && !previewDraft.videoUrl) {
+ errors.gallery_urls = ['Tambahkan minimal satu gambar galeri atau video demo.']
+ }
+
+ setFieldErrors(errors)
+
+ if (Object.keys(errors).length > 0) {
+ toast.error('Lengkapi data wajib sebelum lanjut ke preview.')
+ return false
+ }
+
+ return true
+ }
+
+ const submitDraft = async () => {
+ setIsSubmitting(true)
+ setFieldErrors({})
+
+ const formData = new FormData()
+ formData.set('competition_id', competitionId)
+ formData.set('category_id', previewDraft.categoryId)
+ formData.set('title', previewDraft.title)
+ formData.set('tagline', previewDraft.tagline)
+ formData.set('description', previewDraft.description)
+ formData.set('process_summary', previewDraft.processSummary)
+ formData.set('ai_tools_used', JSON.stringify(previewDraft.aiToolsUsed))
+ formData.set('tech_stacks', JSON.stringify(previewDraft.techStacks))
+ formData.set('demo_url', previewDraft.demoUrl)
+ formData.set('repo_url', previewDraft.repoUrl)
+ formData.set('thumbnail_url', previewDraft.thumbnailUrl)
+ formData.set('thumbnail_key', previewDraft.thumbnailKey)
+ formData.set('gallery_urls', JSON.stringify(previewDraft.galleryUrls))
+ formData.set('gallery_keys', JSON.stringify(previewDraft.galleryKeys))
+ formData.set('video_url', previewDraft.videoUrl)
+
+ const result = await submitCompetitionEntry(formData)
+
+ if (!result.success) {
+ setFieldErrors((result.fieldErrors ?? {}) as Record)
+ toast.error(result.error ?? 'Gagal menyimpan submission.')
+ setIsSubmitting(false)
+ setIsPreviewing(false)
+ return
+ }
+
+ toast.success('Entry kompetisi berhasil dipublikasikan.')
+ router.push(`/competition/${result.slug}`)
+ router.refresh()
+ }
+
+ if (isPreviewing) {
+ return (
+ setIsPreviewing(false)}
+ onSubmit={() => {
+ void submitDraft()
+ }}
+ />
+ )
+ }
+
+ return (
+
+
+ Form submission
+
+
+
+
+
+
+ {fieldErrors.category_id?.map((error) => (
+
+ {error}
+
+ ))}
+
+
+
+
+
setFormState((current) => ({ ...current, title: event.target.value }))}
+ placeholder="Nama proyek atau eksperimenmu"
+ />
+ {fieldErrors.title?.map((error) => (
+
+ {error}
+
+ ))}
+
+
+
+
+
+
setFormState((current) => ({ ...current, tagline: event.target.value }))}
+ placeholder="Satu kalimat pendek yang menjelaskan value utamamu"
+ />
+ {fieldErrors.tagline?.map((error) => (
+
+ {error}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
setFormState((current) => ({ ...current, demoUrl: event.target.value }))}
+ placeholder="https://demo.example.com"
+ />
+ {fieldErrors.demo_url?.map((error) => (
+
+ {error}
+
+ ))}
+
+
+
+
+
setFormState((current) => ({ ...current, repoUrl: event.target.value }))}
+ placeholder="https://github.com/user/repo"
+ />
+ {fieldErrors.repo_url?.map((error) => (
+
+ {error}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
setFormState((current) => ({ ...current, videoUrl: event.target.value }))}
+ placeholder="https://loom.com/... atau https://youtube.com/..."
+ />
+ {fieldErrors.video_url?.map((error) => (
+
+ {error}
+
+ ))}
+
+
+
+
+
+
Thumbnail utama
+
+ Upload satu gambar utama untuk kartu feed dan halaman detail.
+
+
+
+ {formState.thumbnailUrl ? (
+
+
+
+ ) : null}
+
+
(
+
+
+ {formState.thumbnailUrl ? 'Ganti thumbnail' : 'Upload thumbnail'}
+
+ ),
+ allowedContent: () => 'Gambar JPG/PNG/WebP',
+ }}
+ onClientUploadComplete={(files) => {
+ const uploadedFile = files?.[0]
+ if (!uploadedFile) {
+ return
+ }
+
+ setFormState((current) => ({
+ ...current,
+ thumbnailUrl: uploadedFile.url,
+ thumbnailKey: uploadedFile.key,
+ }))
+ toast.success('Thumbnail siap dipakai.')
+ }}
+ onUploadError={(error) => {
+ toast.error(error.message)
+ }}
+ />
+
+ {fieldErrors.thumbnail_url?.map((error) => (
+
+ {error}
+
+ ))}
+
+
+
+
+
Galeri screenshot
+
+ Tambah hingga 5 screenshot. Jika belum ada galeri, isi Video URL.
+
+
+
+ {formState.galleryUrls.length > 0 ? (
+
+ {formState.galleryUrls.map((url, index) => (
+
+
+
+
+
+
+ ))}
+
+ ) : null}
+
+
(
+
+
+ Upload galeri
+
+ ),
+ allowedContent: () => 'Maksimal 5 gambar',
+ }}
+ onClientUploadComplete={(files) => {
+ let addedAny = false
+
+ setFormState((current) => {
+ const availableSlots = Math.max(0, 5 - current.galleryUrls.length)
+ const nextFiles = files.slice(0, availableSlots)
+
+ if (nextFiles.length > 0) {
+ addedAny = true
+ }
+
+ return {
+ ...current,
+ galleryUrls: [...current.galleryUrls, ...nextFiles.map((file) => file.url)].slice(0, 5),
+ galleryKeys: [...current.galleryKeys, ...nextFiles.map((file) => file.key)].slice(0, 5),
+ }
+ })
+
+ if (addedAny) {
+ toast.success('Galeri berhasil ditambahkan.')
+ } else {
+ toast.error('Galeri sudah mencapai batas maksimum 5 gambar.')
+ }
+ }}
+ onUploadError={(error) => {
+ toast.error(error.message)
+ }}
+ />
+
+ {fieldErrors.gallery_urls?.map((error) => (
+
+ {error}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/components/competition/competition-submit-preview.tsx b/components/competition/competition-submit-preview.tsx
new file mode 100644
index 0000000..f401fe1
--- /dev/null
+++ b/components/competition/competition-submit-preview.tsx
@@ -0,0 +1,147 @@
+import Image from 'next/image'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+
+export interface CompetitionDraftSubmission {
+ competitionId: string
+ categoryId: string
+ categoryLabel: string
+ title: string
+ tagline: string
+ description: string
+ processSummary: string
+ aiToolsUsed: string[]
+ techStacks: string[]
+ demoUrl: string
+ repoUrl: string
+ thumbnailUrl: string
+ thumbnailKey: string
+ galleryUrls: string[]
+ galleryKeys: string[]
+ videoUrl: string
+}
+
+interface CompetitionSubmitPreviewProps {
+ draft: CompetitionDraftSubmission
+ isSubmitting: boolean
+ onBack: () => void
+ onSubmit: () => void
+}
+
+export function CompetitionSubmitPreview({ draft, isSubmitting, onBack, onSubmit }: CompetitionSubmitPreviewProps) {
+ return (
+
+
+
+ Review sebelum submit
+
+
+
+
Kategori
+
{draft.categoryLabel}
+
+
+
+
Judul & tagline
+
+
{draft.title}
+
{draft.tagline}
+
+
+
+
+
+
Demo URL
+
{draft.demoUrl}
+
+
+
Repo URL
+
{draft.repoUrl}
+
+
+
+
+
+ {draft.galleryUrls.length > 0 ? (
+
+
Galeri
+
+ {draft.galleryUrls.map((url) => (
+
+
+
+ ))}
+
+
+ ) : null}
+
+
+
+
AI tools
+
{draft.aiToolsUsed.join(', ')}
+
+
+
Tech stack
+
{draft.techStacks.join(', ')}
+
+
+
+
+
Deskripsi
+
{draft.description}
+
+
+
+
Ringkasan proses
+
{draft.processSummary}
+
+
+ {draft.videoUrl ? (
+
+
Video demo
+
{draft.videoUrl}
+
+ ) : null}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/components/competition/competition-timeline.tsx b/components/competition/competition-timeline.tsx
new file mode 100644
index 0000000..9a305af
--- /dev/null
+++ b/components/competition/competition-timeline.tsx
@@ -0,0 +1,34 @@
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import type { CompetitionTimelineItem } from '@/types/competition'
+
+interface CompetitionTimelineProps {
+ items: CompetitionTimelineItem[]
+}
+
+export function CompetitionTimeline({ items }: CompetitionTimelineProps) {
+ if (items.length === 0) {
+ return null
+ }
+
+ return (
+
+
+ Timeline
+
+
+ {items.map((item) => (
+
+
+
{item.label}
+ {item.date}
+
+ {item.description ?
{item.description}
: null}
+
+ ))}
+
+
+ )
+}
diff --git a/components/competition/competition-vote-button.tsx b/components/competition/competition-vote-button.tsx
new file mode 100644
index 0000000..179515a
--- /dev/null
+++ b/components/competition/competition-vote-button.tsx
@@ -0,0 +1,74 @@
+'use client'
+
+import { Heart, Loader2 } from 'lucide-react'
+import { usePathname, useRouter } from 'next/navigation'
+import { useState, useTransition } from 'react'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { toggleCompetitionVote } from '@/lib/actions/competition'
+
+interface CompetitionVoteButtonProps {
+ entryId: string
+ initialVoteCount: number
+ initialHasVoted: boolean
+ isLoggedIn: boolean
+ isOwner: boolean
+ isCompetitionOpen: boolean
+}
+
+export function CompetitionVoteButton({
+ entryId,
+ initialVoteCount,
+ initialHasVoted,
+ isLoggedIn,
+ isOwner,
+ isCompetitionOpen,
+}: CompetitionVoteButtonProps) {
+ const router = useRouter()
+ const pathname = usePathname()
+ const [isPending, startTransition] = useTransition()
+ const [hasVoted, setHasVoted] = useState(initialHasVoted)
+ const [voteCount, setVoteCount] = useState(initialVoteCount)
+
+ const disabled = isPending || isOwner || !isCompetitionOpen
+
+ return (
+
+ )
+}
diff --git a/lib/actions/admin/comments.ts b/lib/actions/admin/comments.ts
index 11eac00..0fe2371 100644
--- a/lib/actions/admin/comments.ts
+++ b/lib/actions/admin/comments.ts
@@ -39,7 +39,7 @@ export interface ReportedComment {
display_name: string
username: string
}
- entity_type: 'post' | 'project'
+ entity_type: 'post' | 'project' | 'competition'
entity_id: string
entity_title: string
}
@@ -138,62 +138,81 @@ export async function getReportedComments(
// Get all comment IDs from reports to batch fetch entity info
const commentIds = (reports || []).map((r) => r.comment_id).filter(Boolean)
- // Batch fetch all comments with their post_id and project_id in a single query
- const { data: allComments } = await supabase.from('comments').select('id, post_id, project_id').in('id', commentIds)
+ // Batch fetch all comments with their linked entities in a single query
+ const { data: allComments } = await supabase
+ .from('comments')
+ .select('id, post_id, project_id, competition_entry_id')
+ .in('id', commentIds)
// Separate comment IDs by entity type
const postCommentIds: string[] = []
- const projectCommentIds: number[] = []
- const commentEntityMap = new Map()
+ const projectCommentIds: string[] = []
+ const competitionCommentIds: string[] = []
+ const commentEntityMap = new Map()
allComments?.forEach((comment) => {
if (comment.post_id) {
- postCommentIds.push(comment.id)
+ postCommentIds.push(comment.post_id)
commentEntityMap.set(comment.id, {
type: 'post',
entityId: comment.post_id,
})
} else if (comment.project_id) {
- projectCommentIds.push(comment.project_id)
+ projectCommentIds.push(String(comment.project_id))
commentEntityMap.set(comment.id, {
type: 'project',
entityId: String(comment.project_id),
})
+ } else if (comment.competition_entry_id) {
+ competitionCommentIds.push(comment.competition_entry_id)
+ commentEntityMap.set(comment.id, {
+ type: 'competition',
+ entityId: comment.competition_entry_id,
+ })
}
})
- // Batch fetch all posts and projects in parallel
- const [postsResult, projectsResult] = await Promise.all([
+ // Batch fetch all posts, projects, and competition entries in parallel
+ const [postsResult, projectsResult, competitionEntriesResult] = await Promise.all([
postCommentIds.length > 0
? supabase.from('posts').select('id, title').in('id', postCommentIds)
: Promise.resolve({ data: [] }),
projectCommentIds.length > 0
? supabase.from('projects').select('id, title').in('id', projectCommentIds)
: Promise.resolve({ data: [] }),
+ competitionCommentIds.length > 0
+ ? supabase.from('competition_entries').select('id, title').in('id', competitionCommentIds)
+ : Promise.resolve({ data: [] }),
])
// Create lookup maps for O(1) access
const postMap = new Map(
(postsResult.data || []).map((p: { id: string; title: string }) => [p.id, p.title]),
)
- const projectMap = new Map(
- (projectsResult.data || []).map((p: { id: number; title: string }) => [p.id, p.title]),
+ const projectMap = new Map(
+ (projectsResult.data || []).map((p: { id: number | string; title: string }) => [String(p.id), p.title]),
+ )
+ const competitionMap = new Map(
+ (competitionEntriesResult.data || []).map((entry: { id: string; title: string }) => [entry.id, entry.title]),
)
// Format reports using the lookup maps (no additional queries)
const formattedReports: ReportedComment[] = (reports || []).map((report) => {
const entityInfo = commentEntityMap.get(report.comment_id)
- let entity_type: 'post' | 'project' = 'post'
+ let entity_type: 'post' | 'project' | 'competition' = 'post'
let entity_id = ''
let entity_title = 'Unknown'
if (entityInfo) {
entity_type = entityInfo.type
entity_id = entityInfo.entityId
- entity_title =
- entityInfo.type === 'post'
- ? postMap.get(entityInfo.entityId) || 'Unknown Post'
- : projectMap.get(Number(entityInfo.entityId)) || 'Unknown Project'
+ if (entityInfo.type === 'post') {
+ entity_title = postMap.get(entityInfo.entityId) || 'Unknown Post'
+ } else if (entityInfo.type === 'project') {
+ entity_title = projectMap.get(entityInfo.entityId) || 'Unknown Project'
+ } else {
+ entity_title = competitionMap.get(entityInfo.entityId) || 'Unknown Competition Entry'
+ }
}
const comment = report.comment
@@ -252,7 +271,11 @@ export async function adminDeleteComment(commentId: string): Promise<{ success:
const supabase = createAdminClient()
// Get comment info for revalidation
- const { data: comment } = await supabase.from('comments').select('post_id, project_id').eq('id', commentId).single()
+ const { data: comment } = await supabase
+ .from('comments')
+ .select('post_id, project_id, competition_entry_id')
+ .eq('id', commentId)
+ .single()
// Delete related reports first
const { error: reportDeleteError } = await supabase.from('blog_reports').delete().eq('comment_id', commentId)
@@ -280,6 +303,11 @@ export async function adminDeleteComment(commentId: string): Promise<{ success:
revalidatePath('/project/[slug]')
revalidatePath('/project/list')
}
+ if (comment?.competition_entry_id) {
+ revalidatePath('/competition')
+ revalidatePath('/competition/list')
+ revalidatePath('/competition/[slug]')
+ }
revalidatePath('/admin/dashboard/boards/comments')
return { success: true }
diff --git a/lib/actions/comments.ts b/lib/actions/comments.ts
index 57c8660..a45b18d 100644
--- a/lib/actions/comments.ts
+++ b/lib/actions/comments.ts
@@ -1,8 +1,8 @@
'use server'
import { revalidatePath } from 'next/cache'
-import type { Comment, CommentEntityType, CommentResult, CreateCommentInput, GetCommentsResult } from '@/types/comments'
import { createClient } from '@/lib/supabase/server'
+import type { Comment, CommentEntityType, CommentResult, CreateCommentInput, GetCommentsResult } from '@/types/comments'
/**
* Raw user data from Supabase join
@@ -67,7 +67,23 @@ const normalizeComment = (raw: RawComment): Comment => {
/**
* Get revalidation path based on entity type
*/
-const getRevalidatePath = (entityType: CommentEntityType): string => (entityType === 'post' ? '/blog' : '/project')
+const COMMENT_TARGET_COLUMN_MAP: Record = {
+ post: 'post_id',
+ project: 'project_id',
+ competition: 'competition_entry_id',
+}
+
+const getRevalidatePath = (entityType: CommentEntityType): string => {
+ switch (entityType) {
+ case 'post':
+ return '/blog'
+ case 'competition':
+ return '/competition'
+ case 'project':
+ default:
+ return '/project'
+ }
+}
/**
* Create a new comment for blog post or project
@@ -87,6 +103,26 @@ export async function createComment(input: CreateCommentInput): Promise = {
content: content.trim(),
@@ -94,14 +130,10 @@ export async function createComment(input: CreateCommentInput): Promise>
+
+interface CompetitionActionResult {
+ success: boolean
+ slug?: string
+ error?: string
+ fieldErrors?: CompetitionFieldErrors
+}
+
+interface CompetitionVoteResult {
+ success: boolean
+ hasVoted?: boolean
+ voteCount?: number
+ error?: string
+}
+
+interface CompetitionSummaryRow {
+ id: string
+ slug: string
+ status: 'draft' | 'active' | 'closed' | 'archived'
+ starts_at: string
+ ends_at: string
+}
+
+interface CompetitionEntryRow {
+ id: string
+ competition_id: string
+ user_id: string
+ slug: string
+ status: 'published' | 'hidden' | 'disqualified'
+ title: string
+ thumbnail_key: string | null
+ gallery_keys: string[] | null
+ vote_count: number | null
+ comments_locked?: boolean | null
+ competition?: CompetitionSummaryRow | CompetitionSummaryRow[] | null
+}
+
+interface CompetitionCategoryRow {
+ id: string
+ competition_id: string
+}
+
+interface CompetitionEntryInput {
+ competitionId: string
+ categoryId: string
+ title: string
+ tagline: string
+ description: string
+ processSummary: string
+ aiToolsUsed: string[]
+ techStacks: string[]
+ demoUrl: string
+ repoUrl: string
+ thumbnailUrl: string
+ thumbnailKey: string | null
+ galleryUrls: string[]
+ galleryKeys: string[]
+ videoUrl: string | null
+}
+
+interface CompetitionValidationSuccess {
+ success: true
+ data: CompetitionEntryInput
+}
+
+interface CompetitionValidationFailure {
+ success: false
+ result: CompetitionActionResult
+}
+
+const COMPETITION_ROOT_PATH = '/competition'
+const COMPETITION_LIST_PATH = '/competition/list'
+const UNEXPECTED_ERROR_MESSAGE = 'Terjadi kesalahan tak terduga.'
+const MAX_TITLE_LENGTH = 120
+const MAX_TAGLINE_LENGTH = 160
+const MAX_DESCRIPTION_LENGTH = 5000
+const MAX_PROCESS_SUMMARY_LENGTH = 3000
+const MAX_GALLERY_IMAGE_COUNT = 5
+const MAX_TECH_STACK_COUNT = 8
+const MAX_AI_TOOL_COUNT = 12
+const MAX_LIST_ITEM_LENGTH = 40
+const MAX_ENTRIES_PER_COMPETITION = 3
+const FIELD_NAME_MAP: Record = {
+ competitionId: 'competition_id',
+ categoryId: 'category_id',
+ title: 'title',
+ tagline: 'tagline',
+ description: 'description',
+ processSummary: 'process_summary',
+ aiToolsUsed: 'ai_tools_used',
+ techStacks: 'tech_stacks',
+ demoUrl: 'demo_url',
+ repoUrl: 'repo_url',
+ thumbnailUrl: 'thumbnail_url',
+ galleryUrls: 'gallery_urls',
+ videoUrl: 'video_url',
+}
+
+const getFormValue = (formData: FormData, fieldName: string): string => {
+ const value = formData.get(fieldName)
+ return typeof value === 'string' ? value.trim() : ''
+}
+
+const normalizeZodIssues = (error: z.ZodError): CompetitionFieldErrors => {
+ const fieldErrors: CompetitionFieldErrors = {}
+
+ for (const issue of error.issues) {
+ const path = issue.path[0]
+ if (typeof path !== 'string') {
+ continue
+ }
+
+ const fieldName = FIELD_NAME_MAP[path]
+ if (!fieldName) {
+ continue
+ }
+
+ const messages = fieldErrors[fieldName] ?? []
+ if (!messages.includes(issue.message)) {
+ messages.push(issue.message)
+ }
+
+ fieldErrors[fieldName] = messages
+ }
+
+ return fieldErrors
+}
+
+const buildValidationErrorResult = (error: z.ZodError): CompetitionActionResult => {
+ const fieldErrors = normalizeZodIssues(error)
+ const firstFieldError = Object.values(fieldErrors).flat()[0]
+
+ return {
+ success: false,
+ error: firstFieldError || 'Periksa kembali data submission Anda.',
+ fieldErrors,
+ }
+}
+
+const addArrayIssue = (ctx: z.RefinementCtx, message: string) => {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message,
+ })
+}
+
+const parseJsonStringArray = (
+ value: string,
+ ctx: z.RefinementCtx,
+ options: {
+ requiredMessage: string
+ invalidMessage: string
+ maxItems: number
+ itemLabel: string
+ maxItemLength?: number
+ minItems?: number
+ },
+): string[] | typeof z.NEVER => {
+ if (!value) {
+ if ((options.minItems ?? 1) > 0) {
+ addArrayIssue(ctx, options.requiredMessage)
+ return z.NEVER
+ }
+ return []
+ }
+
+ try {
+ const parsed = JSON.parse(value)
+ if (!Array.isArray(parsed)) {
+ addArrayIssue(ctx, options.invalidMessage)
+ return z.NEVER
+ }
+
+ const normalized = parsed
+ .filter((item): item is string => typeof item === 'string')
+ .map((item) => item.trim())
+ .filter(Boolean)
+
+ if (normalized.length < (options.minItems ?? 1)) {
+ addArrayIssue(ctx, options.requiredMessage)
+ return z.NEVER
+ }
+
+ if (normalized.length > options.maxItems) {
+ addArrayIssue(ctx, `Maksimal ${options.maxItems} ${options.itemLabel}.`)
+ return z.NEVER
+ }
+
+ const uniqueItems = [...new Set(normalized)]
+ const maxItemLength = options.maxItemLength ?? MAX_LIST_ITEM_LENGTH
+
+ for (const item of uniqueItems) {
+ if (item.length > maxItemLength) {
+ addArrayIssue(ctx, `${options.itemLabel} tidak boleh melebihi ${maxItemLength} karakter.`)
+ return z.NEVER
+ }
+ }
+
+ return uniqueItems
+ } catch {
+ addArrayIssue(ctx, options.invalidMessage)
+ return z.NEVER
+ }
+}
+
+const parseOptionalJsonStringArray = (
+ value: string,
+ ctx: z.RefinementCtx,
+ options: {
+ invalidMessage: string
+ maxItems: number
+ itemLabel: string
+ },
+): string[] | typeof z.NEVER => {
+ if (!value) {
+ return []
+ }
+
+ try {
+ const parsed = JSON.parse(value)
+ if (!Array.isArray(parsed)) {
+ addArrayIssue(ctx, options.invalidMessage)
+ return z.NEVER
+ }
+
+ const normalized = parsed
+ .filter((item): item is string => typeof item === 'string')
+ .map((item) => item.trim())
+ .filter(Boolean)
+
+ if (normalized.length > options.maxItems) {
+ addArrayIssue(ctx, `Maksimal ${options.maxItems} ${options.itemLabel}.`)
+ return z.NEVER
+ }
+
+ return normalized
+ } catch {
+ addArrayIssue(ctx, options.invalidMessage)
+ return z.NEVER
+ }
+}
+
+const normalizeHttpsUrl = (value: string, ctx: z.RefinementCtx, fieldLabel: string): string | typeof z.NEVER => {
+ if (!value) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `${fieldLabel} wajib diisi.`,
+ })
+ return z.NEVER
+ }
+
+ try {
+ const url = new URL(value)
+ if (url.protocol !== 'https:') {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `${fieldLabel} harus menggunakan https://.`,
+ })
+ return z.NEVER
+ }
+
+ return url.toString()
+ } catch {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: `${fieldLabel} tidak valid.`,
+ })
+ return z.NEVER
+ }
+}
+
+const normalizeOptionalHttpsUrl = (
+ value: string,
+ ctx: z.RefinementCtx,
+ fieldLabel: string,
+): string | null | typeof z.NEVER => {
+ if (!value) {
+ return null
+ }
+
+ return normalizeHttpsUrl(value, ctx, fieldLabel)
+}
+
+const buildCompetitionEntrySchema = (validCategoryIds: readonly string[]) => {
+ const categoryIdSet = new Set(validCategoryIds)
+
+ return z
+ .object({
+ competitionId: z.string().uuid('Kompetisi tidak valid.'),
+ categoryId: z.string().uuid('Kategori wajib dipilih.'),
+ title: z
+ .string()
+ .trim()
+ .min(3, 'Judul minimal 3 karakter.')
+ .max(MAX_TITLE_LENGTH, `Judul maksimal ${MAX_TITLE_LENGTH} karakter.`),
+ tagline: z
+ .string()
+ .trim()
+ .min(3, 'Tagline minimal 3 karakter.')
+ .max(MAX_TAGLINE_LENGTH, `Tagline maksimal ${MAX_TAGLINE_LENGTH} karakter.`),
+ description: z
+ .string()
+ .trim()
+ .min(40, 'Deskripsi minimal 40 karakter.')
+ .max(MAX_DESCRIPTION_LENGTH, `Deskripsi maksimal ${MAX_DESCRIPTION_LENGTH} karakter.`),
+ processSummary: z
+ .string()
+ .trim()
+ .min(30, 'Ringkasan proses minimal 30 karakter.')
+ .max(MAX_PROCESS_SUMMARY_LENGTH, `Ringkasan proses maksimal ${MAX_PROCESS_SUMMARY_LENGTH} karakter.`),
+ aiToolsUsed: z.string().transform((value, ctx) =>
+ parseJsonStringArray(value, ctx, {
+ requiredMessage: 'Minimal satu AI tool wajib diisi.',
+ invalidMessage: 'Daftar AI tool tidak valid.',
+ maxItems: MAX_AI_TOOL_COUNT,
+ itemLabel: 'AI tool',
+ }),
+ ),
+ techStacks: z.string().transform((value, ctx) =>
+ parseJsonStringArray(value, ctx, {
+ requiredMessage: 'Minimal satu tech stack wajib diisi.',
+ invalidMessage: 'Daftar tech stack tidak valid.',
+ maxItems: MAX_TECH_STACK_COUNT,
+ itemLabel: 'tech stack',
+ }),
+ ),
+ demoUrl: z.string().transform((value, ctx) => normalizeHttpsUrl(value.trim(), ctx, 'Demo URL')),
+ repoUrl: z.string().transform((value, ctx) => normalizeHttpsUrl(value.trim(), ctx, 'Repo URL')),
+ thumbnailUrl: z.string().transform((value, ctx) => normalizeHttpsUrl(value.trim(), ctx, 'Thumbnail')),
+ thumbnailKey: z.string().trim().nullable().optional(),
+ galleryUrls: z.string().transform((value, ctx) =>
+ parseOptionalJsonStringArray(value, ctx, {
+ invalidMessage: 'Galeri tidak valid.',
+ maxItems: MAX_GALLERY_IMAGE_COUNT,
+ itemLabel: 'gambar galeri',
+ }),
+ ),
+ galleryKeys: z.string().transform((value, ctx) =>
+ parseOptionalJsonStringArray(value, ctx, {
+ invalidMessage: 'Kunci galeri tidak valid.',
+ maxItems: MAX_GALLERY_IMAGE_COUNT,
+ itemLabel: 'kunci galeri',
+ }),
+ ),
+ videoUrl: z.string().transform((value, ctx) => normalizeOptionalHttpsUrl(value.trim(), ctx, 'Video URL')),
+ })
+ .superRefine((input, ctx) => {
+ if (!categoryIdSet.has(input.categoryId)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['categoryId'],
+ message: 'Kategori tidak tersedia untuk kompetisi aktif.',
+ })
+ }
+
+ if (input.galleryUrls.length === 0 && !input.videoUrl) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['galleryUrls'],
+ message: 'Tambahkan minimal satu gambar galeri atau video demo.',
+ })
+ }
+
+ if (input.galleryKeys.length > input.galleryUrls.length) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['galleryUrls'],
+ message: 'Kunci upload galeri tidak sinkron dengan daftar gambar.',
+ })
+ }
+ })
+}
+
+export async function validateCompetitionEntryInput(
+ formData: FormData,
+ validCategoryIds: readonly string[],
+): Promise {
+ const rawInput = {
+ competitionId: getFormValue(formData, 'competition_id'),
+ categoryId: getFormValue(formData, 'category_id'),
+ title: getFormValue(formData, 'title'),
+ tagline: getFormValue(formData, 'tagline'),
+ description: getFormValue(formData, 'description'),
+ processSummary: getFormValue(formData, 'process_summary'),
+ aiToolsUsed: getFormValue(formData, 'ai_tools_used'),
+ techStacks: getFormValue(formData, 'tech_stacks'),
+ demoUrl: getFormValue(formData, 'demo_url'),
+ repoUrl: getFormValue(formData, 'repo_url'),
+ thumbnailUrl: getFormValue(formData, 'thumbnail_url'),
+ thumbnailKey: getFormValue(formData, 'thumbnail_key') || null,
+ galleryUrls: getFormValue(formData, 'gallery_urls'),
+ galleryKeys: getFormValue(formData, 'gallery_keys'),
+ videoUrl: getFormValue(formData, 'video_url'),
+ }
+
+ const schema = buildCompetitionEntrySchema(validCategoryIds)
+ const parsedInput = schema.safeParse(rawInput)
+
+ if (!parsedInput.success) {
+ return {
+ success: false,
+ result: buildValidationErrorResult(parsedInput.error),
+ }
+ }
+
+ return {
+ success: true,
+ data: parsedInput.data,
+ }
+}
+
+export function validateCompetitionMedia(
+ input: Pick,
+) {
+ if (!input.thumbnailUrl) {
+ throw new Error('Thumbnail wajib diisi.')
+ }
+
+ if (input.galleryUrls.length === 0 && !input.videoUrl) {
+ throw new Error('Tambahkan minimal satu gambar galeri atau video demo.')
+ }
+}
+
+function asSingle(value: T | T[] | null | undefined): T | null {
+ if (!value) {
+ return null
+ }
+
+ return Array.isArray(value) ? (value[0] ?? null) : value
+}
+
+function isCompetitionOpen(competition: CompetitionSummaryRow, now: Date = new Date()): boolean {
+ const startsAt = new Date(competition.starts_at)
+ const endsAt = new Date(competition.ends_at)
+ return competition.status === 'active' && now >= startsAt && now < endsAt
+}
+
+export function assertCompetitionIsActive(competition: CompetitionSummaryRow): void {
+ if (!isCompetitionOpen(competition)) {
+ throw new Error('Kompetisi belum dibuka atau sudah ditutup.')
+ }
+}
+
+export function assertUserCanVote({ entry, userId }: { entry: CompetitionEntryRow; userId: string }): void {
+ if (entry.user_id === userId) {
+ throw new Error('Kamu tidak bisa vote entry milik sendiri.')
+ }
+
+ if (entry.status !== 'published') {
+ throw new Error('Entry ini tidak tersedia untuk voting.')
+ }
+
+ const competition = asSingle(entry.competition)
+ if (!competition) {
+ throw new Error('Kompetisi tidak ditemukan.')
+ }
+
+ assertCompetitionIsActive(competition)
+}
+
+async function getAuthenticatedUser() {
+ const supabase = await createClient()
+ const {
+ data: { user },
+ error,
+ } = await supabase.auth.getUser()
+
+ if (error || !user) {
+ throw new Error('Kamu harus login untuk melakukan aksi ini.')
+ }
+
+ return { supabase, user }
+}
+
+async function checkCompetitionAdminAccess() {
+ const { supabase, user } = await getAuthenticatedUser()
+ const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).maybeSingle()
+
+ if (!profile || ![0, 1].includes(profile.role ?? 2)) {
+ throw new Error('Akses admin diperlukan.')
+ }
+
+ return user
+}
+
+async function getCompetitionForSubmission(
+ supabase: Awaited>,
+ competitionId: string,
+): Promise {
+ const { data } = await supabase
+ .from('competitions')
+ .select('id, slug, status, starts_at, ends_at')
+ .eq('id', competitionId)
+ .maybeSingle()
+
+ return (data as CompetitionSummaryRow | null) ?? null
+}
+
+async function getCompetitionCategoryIds(
+ supabase: Awaited>,
+ competitionId: string,
+): Promise {
+ const { data } = await supabase
+ .from('competition_categories')
+ .select('id')
+ .eq('competition_id', competitionId)
+ .eq('is_active', true)
+
+ return (data as CompetitionCategoryRow[] | null)?.map((category) => category.id) ?? []
+}
+
+async function ensureUniqueCompetitionEntrySlug(
+ supabase: Awaited>,
+ title: string,
+): Promise {
+ const baseSlug = slugifyTitle(title, 80) || 'entry'
+ let slug = baseSlug
+ let suffix = 1
+
+ while (suffix <= 100) {
+ const { data } = await supabase.from('competition_entries').select('id').eq('slug', slug).limit(1)
+ if (!data || data.length === 0) {
+ return slug
+ }
+
+ suffix += 1
+ slug = `${baseSlug}-${suffix}`
+ }
+
+ return `${baseSlug}-${Date.now()}`
+}
+
+function revalidateCompetition(entrySlug?: string, competitionId?: string, entryId?: string) {
+ revalidatePath(COMPETITION_ROOT_PATH)
+ revalidatePath(COMPETITION_LIST_PATH)
+ revalidatePath('/competition/submit')
+
+ if (entrySlug) {
+ revalidatePath(`/competition/${entrySlug}`)
+ }
+
+ revalidateTag('competition:active', 'max')
+
+ if (competitionId) {
+ revalidateTag(`competition:landing:${competitionId}`, 'max')
+ revalidateTag(`competition:entries:${competitionId}`, 'max')
+ }
+
+ if (entryId) {
+ revalidateTag(`competition:entry:${entryId}`, 'max')
+ }
+}
+
+async function syncCompetitionEntryVoteCount(entryId: string): Promise {
+ const adminClient = createAdminClient()
+ const { count } = await adminClient
+ .from('competition_votes')
+ .select('id', { count: 'exact', head: true })
+ .eq('competition_entry_id', entryId)
+
+ const voteCount = count ?? 0
+ await adminClient.from('competition_entries').update({ vote_count: voteCount }).eq('id', entryId)
+ return voteCount
+}
+
+export async function getCurrentUserCompetitionEntryCount(
+ competitionId: string,
+): Promise<{ success: boolean; count?: number; error?: string }> {
+ try {
+ const { supabase, user } = await getAuthenticatedUser()
+ const { count, error } = await supabase
+ .from('competition_entries')
+ .select('id', { count: 'exact', head: true })
+ .eq('competition_id', competitionId)
+ .eq('user_id', user.id)
+ .is('deleted_at', null)
+
+ if (error) {
+ return { success: false, error: 'Gagal mengambil jumlah submission.' }
+ }
+
+ return { success: true, count: count ?? 0 }
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : UNEXPECTED_ERROR_MESSAGE,
+ }
+ }
+}
+
+export async function submitCompetitionEntry(formData: FormData): Promise {
+ let uploadKeysToCleanup: string[] = []
+
+ try {
+ const { supabase, user } = await getAuthenticatedUser()
+ const competitionId = getFormValue(formData, 'competition_id')
+
+ const competition = await getCompetitionForSubmission(supabase, competitionId)
+ if (!competition) {
+ return { success: false, error: 'Kompetisi aktif tidak ditemukan.' }
+ }
+
+ assertCompetitionIsActive(competition)
+
+ const validCategoryIds = await getCompetitionCategoryIds(supabase, competition.id)
+ const validationResult = await validateCompetitionEntryInput(formData, validCategoryIds)
+
+ if (!validationResult.success) {
+ return validationResult.result
+ }
+
+ const input = validationResult.data
+ validateCompetitionMedia(input)
+ uploadKeysToCleanup = [input.thumbnailKey, ...input.galleryKeys].filter(Boolean) as string[]
+
+ const { count } = await supabase
+ .from('competition_entries')
+ .select('id', { count: 'exact', head: true })
+ .eq('competition_id', input.competitionId)
+ .eq('user_id', user.id)
+ .is('deleted_at', null)
+
+ if ((count ?? 0) >= MAX_ENTRIES_PER_COMPETITION) {
+ return {
+ success: false,
+ error: `Maksimal ${MAX_ENTRIES_PER_COMPETITION} submission per kompetisi.`,
+ }
+ }
+
+ const slug = await ensureUniqueCompetitionEntrySlug(supabase, input.title)
+ const { data, error } = await supabase
+ .from('competition_entries')
+ .insert({
+ competition_id: input.competitionId,
+ user_id: user.id,
+ category_id: input.categoryId,
+ slug,
+ title: input.title,
+ tagline: input.tagline,
+ description: input.description,
+ process_summary: input.processSummary,
+ ai_tools_used: input.aiToolsUsed,
+ tech_stacks: input.techStacks,
+ demo_url: input.demoUrl,
+ repo_url: input.repoUrl,
+ thumbnail_url: input.thumbnailUrl,
+ thumbnail_key: input.thumbnailKey,
+ gallery_urls: input.galleryUrls,
+ gallery_keys: input.galleryKeys,
+ video_url: input.videoUrl,
+ status: 'published',
+ submitted_at: new Date().toISOString(),
+ })
+ .select('id, slug')
+ .single()
+
+ if (error || !data) {
+ await deleteUploadthingFiles(uploadKeysToCleanup)
+ return {
+ success: false,
+ error: 'Gagal menyimpan submission kompetisi.',
+ }
+ }
+
+ revalidateCompetition(data.slug, input.competitionId, data.id)
+ return {
+ success: true,
+ slug: data.slug,
+ }
+ } catch (error) {
+ if (uploadKeysToCleanup.length > 0) {
+ await deleteUploadthingFiles(uploadKeysToCleanup)
+ }
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : UNEXPECTED_ERROR_MESSAGE,
+ }
+ }
+}
+
+export async function deleteCompetitionEntry(entryId: string): Promise {
+ try {
+ const { supabase, user } = await getAuthenticatedUser()
+ const { data: entry } = await supabase
+ .from('competition_entries')
+ .select(
+ 'id, competition_id, user_id, slug, thumbnail_key, gallery_keys, competition:competitions(id, slug, status, starts_at, ends_at)',
+ )
+ .eq('id', entryId)
+ .maybeSingle()
+
+ const competitionEntry = entry as CompetitionEntryRow | null
+ if (!competitionEntry) {
+ return { success: false, error: 'Entry tidak ditemukan.' }
+ }
+
+ if (competitionEntry.user_id !== user.id) {
+ return { success: false, error: 'Kamu tidak bisa menghapus entry ini.' }
+ }
+
+ const competition = asSingle(competitionEntry.competition)
+ if (!competition) {
+ return { success: false, error: 'Kompetisi tidak ditemukan.' }
+ }
+
+ assertCompetitionIsActive(competition)
+
+ const { error } = await supabase.from('competition_entries').delete().eq('id', entryId)
+ if (error) {
+ return { success: false, error: 'Gagal menghapus entry.' }
+ }
+
+ const keysToDelete = [competitionEntry.thumbnail_key, ...(competitionEntry.gallery_keys ?? [])].filter(
+ Boolean,
+ ) as string[]
+ if (keysToDelete.length > 0) {
+ await deleteUploadthingFiles(keysToDelete)
+ }
+
+ revalidateCompetition(competitionEntry.slug, competition.id, competitionEntry.id)
+ return { success: true }
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : UNEXPECTED_ERROR_MESSAGE,
+ }
+ }
+}
+
+export async function toggleCompetitionVote(entryId: string): Promise {
+ try {
+ const { supabase, user } = await getAuthenticatedUser()
+ const { data: entry } = await supabase
+ .from('competition_entries')
+ .select(
+ 'id, competition_id, user_id, slug, status, vote_count, competition:competitions(id, slug, status, starts_at, ends_at)',
+ )
+ .eq('id', entryId)
+ .maybeSingle()
+
+ const competitionEntry = entry as CompetitionEntryRow | null
+ if (!competitionEntry) {
+ return { success: false, error: 'Entry tidak ditemukan.' }
+ }
+
+ assertUserCanVote({
+ entry: competitionEntry,
+ userId: user.id,
+ })
+
+ const existingVote = await supabase
+ .from('competition_votes')
+ .select('id')
+ .eq('competition_entry_id', entryId)
+ .eq('user_id', user.id)
+ .maybeSingle()
+
+ let hasVoted = false
+
+ if (existingVote.data?.id) {
+ const { error } = await supabase.from('competition_votes').delete().eq('id', existingVote.data.id)
+ if (error) {
+ return { success: false, error: 'Gagal membatalkan vote.' }
+ }
+ } else {
+ const { error } = await supabase.from('competition_votes').insert({
+ competition_entry_id: entryId,
+ competition_id: competitionEntry.competition_id,
+ user_id: user.id,
+ })
+
+ if (error) {
+ if (error.code === '23505') {
+ return { success: true, hasVoted: true, voteCount: competitionEntry.vote_count ?? 0 }
+ }
+
+ return { success: false, error: 'Gagal menyimpan vote.' }
+ }
+
+ hasVoted = true
+ }
+
+ const nextVoteCount = await syncCompetitionEntryVoteCount(entryId)
+ revalidateCompetition(competitionEntry.slug, competitionEntry.competition_id, competitionEntry.id)
+
+ return {
+ success: true,
+ hasVoted,
+ voteCount: nextVoteCount,
+ }
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : UNEXPECTED_ERROR_MESSAGE,
+ }
+ }
+}
+
+export async function reportCompetitionEntry(entryId: string, reason: string): Promise {
+ try {
+ const { supabase, user } = await getAuthenticatedUser()
+ const normalizedReason = reason.trim()
+
+ if (!normalizedReason) {
+ return { success: false, error: 'Alasan laporan wajib diisi.' }
+ }
+
+ const { error } = await supabase.from('competition_entry_reports').insert({
+ competition_entry_id: entryId,
+ reporter_user_id: user.id,
+ reason: normalizedReason,
+ })
+
+ if (error) {
+ if (error.code === '23505') {
+ return { success: false, error: 'Kamu sudah melaporkan entry ini.' }
+ }
+
+ return { success: false, error: 'Gagal mengirim laporan.' }
+ }
+
+ return { success: true }
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : UNEXPECTED_ERROR_MESSAGE,
+ }
+ }
+}
+
+async function updateCompetitionEntryAdminState(
+ entryId: string,
+ updates: Record,
+): Promise {
+ try {
+ await checkCompetitionAdminAccess()
+ const adminClient = createAdminClient()
+ const { data, error } = await adminClient
+ .from('competition_entries')
+ .update(updates)
+ .eq('id', entryId)
+ .select('id, slug, competition_id')
+ .single()
+
+ if (error || !data) {
+ return { success: false, error: 'Gagal memperbarui status entry.' }
+ }
+
+ revalidateCompetition(data.slug, data.competition_id, data.id)
+ return { success: true }
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : UNEXPECTED_ERROR_MESSAGE,
+ }
+ }
+}
+
+export async function hideCompetitionEntry(entryId: string) {
+ return updateCompetitionEntryAdminState(entryId, { status: 'hidden' })
+}
+
+export async function disqualifyCompetitionEntry(entryId: string, reason?: string) {
+ return updateCompetitionEntryAdminState(entryId, {
+ status: 'disqualified',
+ moderation_reason: reason?.trim() || null,
+ })
+}
+
+export async function featureCompetitionEntry(entryId: string, featured: boolean) {
+ return updateCompetitionEntryAdminState(entryId, { is_featured: featured })
+}
+
+export async function lockCompetitionEntryComments(entryId: string, locked: boolean) {
+ return updateCompetitionEntryAdminState(entryId, { comments_locked: locked })
+}
+
+export async function markCompetitionEntryFinalist(entryId: string, finalist: boolean) {
+ return updateCompetitionEntryAdminState(entryId, { is_finalist: finalist })
+}
+
+export async function markCompetitionEntryWinner(entryId: string, winner: boolean) {
+ return updateCompetitionEntryAdminState(entryId, { is_winner: winner })
+}
diff --git a/lib/server/competition-public.ts b/lib/server/competition-public.ts
new file mode 100644
index 0000000..f49f374
--- /dev/null
+++ b/lib/server/competition-public.ts
@@ -0,0 +1,472 @@
+import { createClient as createSupabaseClient } from '@supabase/supabase-js'
+import { unstable_cache } from 'next/cache'
+import { getSupabaseConfig } from '@/lib/env-config'
+import type {
+ Competition,
+ CompetitionCategory,
+ CompetitionEntriesResult,
+ CompetitionEntryAuthor,
+ CompetitionEntryDetail,
+ CompetitionEntrySummary,
+ CompetitionFaqItem,
+ CompetitionSort,
+ CompetitionStatus,
+ CompetitionTimelineItem,
+} from '@/types/competition'
+
+interface CompetitionRow {
+ id: string
+ slug: string
+ title: string
+ tagline: string
+ description: string
+ prize_text: string
+ starts_at: string
+ ends_at: string
+ status: CompetitionStatus
+ rules_markdown: string
+ judging_criteria_markdown: string
+ faq_items: unknown
+ timeline_items: unknown
+ hero_primary_cta_label: string | null
+ hero_secondary_cta_label: string | null
+ judging_vote_weight: number | string | null
+ judging_judge_weight: number | string | null
+ created_at: string
+ updated_at: string
+}
+
+interface CompetitionCategoryRow {
+ id: string
+ competition_id: string
+ slug: string
+ label: string
+ description: string | null
+ sort_order: number | null
+ is_active: boolean | null
+}
+
+interface CompetitionEntryAuthorRow {
+ id: string
+ display_name: string | null
+ username: string | null
+ avatar_url: string | null
+ role: number | null
+}
+
+interface CompetitionEntryRow {
+ id: string
+ competition_id: string
+ category_id: string
+ user_id: string
+ slug: string
+ title: string
+ tagline: string
+ description: string
+ process_summary: string
+ ai_tools_used: string[] | null
+ tech_stacks: string[] | null
+ demo_url: string
+ repo_url: string
+ thumbnail_url: string
+ thumbnail_key: string | null
+ gallery_urls: string[] | null
+ gallery_keys: string[] | null
+ video_url: string | null
+ comment_count: number | null
+ vote_count: number | null
+ status: CompetitionEntrySummary['status']
+ is_featured: boolean | null
+ is_finalist: boolean | null
+ is_winner: boolean | null
+ comments_locked: boolean | null
+ submitted_at: string
+ created_at: string
+ competition?: CompetitionRow | CompetitionRow[] | null
+ author?: CompetitionEntryAuthorRow | CompetitionEntryAuthorRow[] | null
+ category?: CompetitionCategoryRow | CompetitionCategoryRow[] | null
+}
+
+function createPublicClient() {
+ const { url, anonKey } = getSupabaseConfig()
+
+ return createSupabaseClient(url, anonKey, {
+ auth: {
+ autoRefreshToken: false,
+ persistSession: false,
+ },
+ })
+}
+
+function asArray(value: unknown): T[] {
+ return Array.isArray(value) ? (value as T[]) : []
+}
+
+function asSingle(value: T | T[] | null | undefined): T | null {
+ if (!value) {
+ return null
+ }
+
+ return Array.isArray(value) ? (value[0] ?? null) : value
+}
+
+function normalizeFaqItems(value: unknown): CompetitionFaqItem[] {
+ return asArray>(value)
+ .map((item) => ({
+ question: typeof item.question === 'string' ? item.question : '',
+ answer: typeof item.answer === 'string' ? item.answer : '',
+ }))
+ .filter((item) => item.question && item.answer)
+}
+
+function normalizeTimelineItems(value: unknown): CompetitionTimelineItem[] {
+ return asArray>(value)
+ .map((item) => ({
+ label: typeof item.label === 'string' ? item.label : '',
+ description: typeof item.description === 'string' ? item.description : '',
+ date: typeof item.date === 'string' ? item.date : '',
+ }))
+ .filter((item) => item.label && item.date)
+}
+
+function mapCompetition(row: CompetitionRow): Competition {
+ return {
+ id: row.id,
+ slug: row.slug,
+ title: row.title,
+ tagline: row.tagline,
+ description: row.description,
+ prizeText: row.prize_text,
+ startsAt: row.starts_at,
+ endsAt: row.ends_at,
+ status: row.status,
+ rulesMarkdown: row.rules_markdown,
+ judgingCriteriaMarkdown: row.judging_criteria_markdown,
+ faqItems: normalizeFaqItems(row.faq_items),
+ timelineItems: normalizeTimelineItems(row.timeline_items),
+ heroPrimaryCtaLabel: row.hero_primary_cta_label,
+ heroSecondaryCtaLabel: row.hero_secondary_cta_label,
+ judgingVoteWeight: Number(row.judging_vote_weight ?? 0.3),
+ judgingJudgeWeight: Number(row.judging_judge_weight ?? 0.7),
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ }
+}
+
+function mapCompetitionCategory(row: CompetitionCategoryRow): CompetitionCategory {
+ return {
+ id: row.id,
+ competitionId: row.competition_id,
+ slug: row.slug,
+ label: row.label,
+ description: row.description,
+ sortOrder: row.sort_order ?? 0,
+ isActive: row.is_active ?? true,
+ }
+}
+
+function mapCompetitionAuthor(row: CompetitionEntryAuthorRow | null): CompetitionEntryAuthor | null {
+ if (!row) {
+ return null
+ }
+
+ return {
+ id: row.id,
+ displayName: row.display_name || row.username || 'Peserta',
+ username: row.username || 'user',
+ avatarUrl: row.avatar_url,
+ role: row.role,
+ }
+}
+
+function mapCompetitionEntrySummary(row: CompetitionEntryRow, commentCountOverride?: number): CompetitionEntrySummary {
+ const category = asSingle(row.category)
+
+ return {
+ id: row.id,
+ competitionId: row.competition_id,
+ categoryId: row.category_id,
+ userId: row.user_id,
+ slug: row.slug,
+ title: row.title,
+ tagline: row.tagline,
+ thumbnailUrl: row.thumbnail_url,
+ voteCount: row.vote_count ?? 0,
+ commentCount: commentCountOverride ?? row.comment_count ?? 0,
+ status: row.status,
+ isFeatured: row.is_featured ?? false,
+ isFinalist: row.is_finalist ?? false,
+ isWinner: row.is_winner ?? false,
+ commentsLocked: row.comments_locked ?? false,
+ submittedAt: row.submitted_at,
+ createdAt: row.created_at,
+ category: category ? mapCompetitionCategory(category) : null,
+ author: mapCompetitionAuthor(asSingle(row.author)),
+ }
+}
+
+async function fetchActiveCompetition(): Promise {
+ const supabase = createPublicClient()
+ const nowIso = new Date().toISOString()
+ const { data } = await supabase
+ .from('competitions')
+ .select('*')
+ .eq('status', 'active')
+ .lte('starts_at', nowIso)
+ .gt('ends_at', nowIso)
+ .order('starts_at', { ascending: false })
+ .limit(1)
+ .maybeSingle()
+
+ return data ? mapCompetition(data as CompetitionRow) : null
+}
+
+export const getActiveCompetition = unstable_cache(fetchActiveCompetition, ['competition-active'], {
+ revalidate: 60,
+ tags: ['competition:active'],
+})
+
+export async function getCompetitionLandingData(slugOrActive?: string): Promise {
+ if (!slugOrActive) {
+ return getActiveCompetition()
+ }
+
+ const supabase = createPublicClient()
+ const { data } = await supabase.from('competitions').select('*').eq('slug', slugOrActive).maybeSingle()
+ return data ? mapCompetition(data as CompetitionRow) : null
+}
+
+export async function getCompetitionCategories(competitionId: string): Promise {
+ if (!competitionId) {
+ return []
+ }
+
+ const supabase = createPublicClient()
+ const { data } = await supabase
+ .from('competition_categories')
+ .select('*')
+ .eq('competition_id', competitionId)
+ .eq('is_active', true)
+ .order('sort_order', { ascending: true })
+
+ return (data as CompetitionCategoryRow[] | null)?.map(mapCompetitionCategory) ?? []
+}
+
+export async function getCompetitionEntryVoteState(
+ entryIds: string[],
+ userId?: string,
+): Promise> {
+ if (!userId || entryIds.length === 0) {
+ return {}
+ }
+
+ const supabase = createPublicClient()
+ const { data } = await supabase
+ .from('competition_votes')
+ .select('competition_entry_id')
+ .eq('user_id', userId)
+ .in('competition_entry_id', entryIds)
+
+ return (data ?? []).reduce>((accumulator, row) => {
+ const entryId = typeof row.competition_entry_id === 'string' ? row.competition_entry_id : null
+ if (entryId) {
+ accumulator[entryId] = true
+ }
+ return accumulator
+ }, {})
+}
+
+export async function getCompetitionEntryCommentsCount(entryIds: string[]): Promise> {
+ if (entryIds.length === 0) {
+ return {}
+ }
+
+ const supabase = createPublicClient()
+ const { data } = await supabase.from('comments').select('competition_entry_id').in('competition_entry_id', entryIds)
+
+ return (data ?? []).reduce>((accumulator, row) => {
+ const entryId = typeof row.competition_entry_id === 'string' ? row.competition_entry_id : null
+ if (entryId) {
+ accumulator[entryId] = (accumulator[entryId] ?? 0) + 1
+ }
+ return accumulator
+ }, {})
+}
+
+interface GetCompetitionEntriesOptions {
+ competitionId: string
+ sort?: CompetitionSort
+ categorySlug?: string
+ limit?: number
+ userId?: string
+}
+
+export async function getCompetitionEntries({
+ competitionId,
+ sort = 'newest',
+ categorySlug,
+ limit = 50,
+ userId,
+}: GetCompetitionEntriesOptions): Promise {
+ if (!competitionId) {
+ return null
+ }
+
+ void userId
+
+ const supabase = createPublicClient()
+ const { data: competitionData } = await supabase
+ .from('competitions')
+ .select('*')
+ .eq('id', competitionId)
+ .maybeSingle()
+ const competition = competitionData ? mapCompetition(competitionData as CompetitionRow) : null
+
+ if (!competition) {
+ return null
+ }
+
+ let query = supabase
+ .from('competition_entries')
+ .select(
+ `
+ id,
+ competition_id,
+ category_id,
+ user_id,
+ slug,
+ title,
+ tagline,
+ description,
+ process_summary,
+ ai_tools_used,
+ tech_stacks,
+ demo_url,
+ repo_url,
+ thumbnail_url,
+ thumbnail_key,
+ gallery_urls,
+ gallery_keys,
+ video_url,
+ comment_count,
+ vote_count,
+ status,
+ is_featured,
+ is_finalist,
+ is_winner,
+ comments_locked,
+ submitted_at,
+ created_at,
+ author:users(id, display_name, username, avatar_url, role),
+ category:competition_categories(id, competition_id, slug, label, description, sort_order, is_active)
+ `,
+ )
+ .eq('competition_id', competitionId)
+ .eq('status', 'published')
+ .is('deleted_at', null)
+ .limit(limit)
+
+ if (categorySlug) {
+ query = query.eq('category.slug', categorySlug)
+ }
+
+ switch (sort) {
+ case 'oldest':
+ query = query.order('created_at', { ascending: true })
+ break
+ case 'top':
+ query = query.order('vote_count', { ascending: false }).order('submitted_at', { ascending: true })
+ break
+ case 'newest':
+ default:
+ query = query.order('created_at', { ascending: false })
+ break
+ }
+
+ const { data } = await query
+ const rows = (data as CompetitionEntryRow[] | null) ?? []
+ const commentCounts = await getCompetitionEntryCommentsCount(rows.map((entry) => entry.id))
+
+ return {
+ competition,
+ entries: rows.map((row) => mapCompetitionEntrySummary(row, commentCounts[row.id])),
+ }
+}
+
+export async function getCompetitionEntryBySlug(slug: string, userId?: string): Promise {
+ if (!slug) {
+ return null
+ }
+
+ const supabase = createPublicClient()
+ const { data } = await supabase
+ .from('competition_entries')
+ .select(
+ `
+ id,
+ competition_id,
+ category_id,
+ user_id,
+ slug,
+ title,
+ tagline,
+ description,
+ process_summary,
+ ai_tools_used,
+ tech_stacks,
+ demo_url,
+ repo_url,
+ thumbnail_url,
+ thumbnail_key,
+ gallery_urls,
+ gallery_keys,
+ video_url,
+ comment_count,
+ vote_count,
+ status,
+ is_featured,
+ is_finalist,
+ is_winner,
+ comments_locked,
+ submitted_at,
+ created_at,
+ competition:competitions(*),
+ author:users(id, display_name, username, avatar_url, role),
+ category:competition_categories(id, competition_id, slug, label, description, sort_order, is_active)
+ `,
+ )
+ .eq('slug', slug)
+ .eq('status', 'published')
+ .is('deleted_at', null)
+ .maybeSingle()
+
+ const row = data as CompetitionEntryRow | null
+ if (!row) {
+ return null
+ }
+
+ const competitionRow = asSingle(row.competition)
+ if (!competitionRow) {
+ return null
+ }
+
+ const voteState = await getCompetitionEntryVoteState([row.id], userId)
+ const commentCounts = await getCompetitionEntryCommentsCount([row.id])
+ const summary = mapCompetitionEntrySummary(row, commentCounts[row.id])
+
+ return {
+ ...summary,
+ description: row.description,
+ processSummary: row.process_summary,
+ aiToolsUsed: row.ai_tools_used ?? [],
+ techStacks: row.tech_stacks ?? [],
+ demoUrl: row.demo_url,
+ repoUrl: row.repo_url,
+ thumbnailKey: row.thumbnail_key,
+ galleryUrls: row.gallery_urls ?? [],
+ galleryKeys: row.gallery_keys ?? [],
+ videoUrl: row.video_url,
+ competition: mapCompetition(competitionRow),
+ hasVoted: Boolean(voteState[row.id]),
+ }
+}
diff --git a/scripts/23_add_competitions_tables.sql b/scripts/23_add_competitions_tables.sql
new file mode 100644
index 0000000..6016b10
--- /dev/null
+++ b/scripts/23_add_competitions_tables.sql
@@ -0,0 +1,357 @@
+-- Mini Vibeathon competition domain
+-- Adds reusable competition tables, RLS, and indexes.
+
+CREATE TABLE IF NOT EXISTS public.competitions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ slug TEXT NOT NULL UNIQUE,
+ title TEXT NOT NULL,
+ tagline TEXT NOT NULL,
+ description TEXT NOT NULL,
+ prize_text TEXT NOT NULL,
+ starts_at TIMESTAMPTZ NOT NULL,
+ ends_at TIMESTAMPTZ NOT NULL,
+ status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'closed', 'archived')),
+ rules_markdown TEXT NOT NULL DEFAULT '',
+ judging_criteria_markdown TEXT NOT NULL DEFAULT '',
+ faq_items JSONB NOT NULL DEFAULT '[]'::jsonb,
+ timeline_items JSONB NOT NULL DEFAULT '[]'::jsonb,
+ hero_primary_cta_label TEXT,
+ hero_secondary_cta_label TEXT,
+ judging_vote_weight NUMERIC(4, 2) NOT NULL DEFAULT 0.30,
+ judging_judge_weight NUMERIC(4, 2) NOT NULL DEFAULT 0.70,
+ created_by UUID REFERENCES public.users(id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CONSTRAINT competitions_starts_before_ends CHECK (starts_at < ends_at),
+ CONSTRAINT competitions_weights_sum_to_one CHECK ((judging_vote_weight + judging_judge_weight) = 1.00)
+);
+
+CREATE TABLE IF NOT EXISTS public.competition_categories (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ competition_id UUID NOT NULL REFERENCES public.competitions(id) ON DELETE CASCADE,
+ slug TEXT NOT NULL,
+ label TEXT NOT NULL,
+ description TEXT,
+ sort_order INTEGER NOT NULL DEFAULT 0,
+ is_active BOOLEAN NOT NULL DEFAULT true,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CONSTRAINT competition_categories_unique_slug UNIQUE (competition_id, slug)
+);
+
+CREATE TABLE IF NOT EXISTS public.competition_entries (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ competition_id UUID NOT NULL REFERENCES public.competitions(id) ON DELETE CASCADE,
+ user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
+ category_id UUID NOT NULL REFERENCES public.competition_categories(id) ON DELETE RESTRICT,
+ slug TEXT NOT NULL UNIQUE,
+ title TEXT NOT NULL,
+ tagline TEXT NOT NULL,
+ description TEXT NOT NULL,
+ process_summary TEXT NOT NULL,
+ ai_tools_used TEXT[] NOT NULL DEFAULT '{}'::text[],
+ tech_stacks TEXT[] NOT NULL DEFAULT '{}'::text[],
+ demo_url TEXT NOT NULL,
+ repo_url TEXT NOT NULL,
+ thumbnail_url TEXT NOT NULL,
+ thumbnail_key TEXT,
+ gallery_urls TEXT[] NOT NULL DEFAULT '{}'::text[],
+ gallery_keys TEXT[] NOT NULL DEFAULT '{}'::text[],
+ video_url TEXT,
+ comment_count INTEGER NOT NULL DEFAULT 0,
+ vote_count INTEGER NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'published' CHECK (status IN ('published', 'hidden', 'disqualified')),
+ is_featured BOOLEAN NOT NULL DEFAULT false,
+ is_finalist BOOLEAN NOT NULL DEFAULT false,
+ is_winner BOOLEAN NOT NULL DEFAULT false,
+ comments_locked BOOLEAN NOT NULL DEFAULT false,
+ moderation_reason TEXT,
+ submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CONSTRAINT competition_entries_demo_https CHECK (demo_url ~ '^https://'),
+ CONSTRAINT competition_entries_repo_https CHECK (repo_url ~ '^https://'),
+ CONSTRAINT competition_entries_video_https CHECK (video_url IS NULL OR video_url ~ '^https://'),
+ CONSTRAINT competition_entries_gallery_limit CHECK (COALESCE(array_length(gallery_urls, 1), 0) <= 5),
+ CONSTRAINT competition_entries_gallery_keys_limit CHECK (COALESCE(array_length(gallery_keys, 1), 0) <= 5),
+ CONSTRAINT competition_entries_gallery_pairing CHECK (COALESCE(array_length(gallery_keys, 1), 0) <= COALESCE(array_length(gallery_urls, 1), 0)),
+ CONSTRAINT competition_entries_tech_stack_limit CHECK (COALESCE(array_length(tech_stacks, 1), 0) <= 8),
+ CONSTRAINT competition_entries_has_media CHECK (video_url IS NOT NULL OR COALESCE(array_length(gallery_urls, 1), 0) > 0)
+);
+
+CREATE TABLE IF NOT EXISTS public.competition_votes (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ competition_entry_id UUID NOT NULL REFERENCES public.competition_entries(id) ON DELETE CASCADE,
+ competition_id UUID NOT NULL REFERENCES public.competitions(id) ON DELETE CASCADE,
+ user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CONSTRAINT competition_votes_unique_vote UNIQUE (competition_entry_id, user_id)
+);
+
+CREATE TABLE IF NOT EXISTS public.competition_judge_scores (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ competition_entry_id UUID NOT NULL REFERENCES public.competition_entries(id) ON DELETE CASCADE,
+ competition_id UUID NOT NULL REFERENCES public.competitions(id) ON DELETE CASCADE,
+ judge_user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
+ execution_score NUMERIC(5, 2) NOT NULL DEFAULT 0,
+ creativity_score NUMERIC(5, 2) NOT NULL DEFAULT 0,
+ ai_usage_score NUMERIC(5, 2) NOT NULL DEFAULT 0,
+ ux_score NUMERIC(5, 2) NOT NULL DEFAULT 0,
+ completeness_score NUMERIC(5, 2) NOT NULL DEFAULT 0,
+ notes TEXT,
+ total_score NUMERIC(6, 2) NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CONSTRAINT competition_judge_scores_unique_judge UNIQUE (competition_entry_id, judge_user_id)
+);
+
+CREATE TABLE IF NOT EXISTS public.competition_entry_reports (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ competition_entry_id UUID NOT NULL REFERENCES public.competition_entries(id) ON DELETE CASCADE,
+ reporter_user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
+ reason TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CONSTRAINT competition_entry_reports_unique_report UNIQUE (competition_entry_id, reporter_user_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_competitions_status_starts_at
+ ON public.competitions(status, starts_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_competitions_status_ends_at
+ ON public.competitions(status, ends_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_competition_categories_competition_sort
+ ON public.competition_categories(competition_id, sort_order);
+
+CREATE INDEX IF NOT EXISTS idx_competition_entries_competition_newest
+ ON public.competition_entries(competition_id, created_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_competition_entries_competition_oldest
+ ON public.competition_entries(competition_id, created_at ASC);
+
+CREATE INDEX IF NOT EXISTS idx_competition_entries_competition_category
+ ON public.competition_entries(competition_id, category_id, created_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_competition_entries_competition_user
+ ON public.competition_entries(competition_id, user_id);
+
+CREATE INDEX IF NOT EXISTS idx_competition_entries_published_top
+ ON public.competition_entries(competition_id, vote_count DESC, submitted_at ASC)
+ WHERE status = 'published' AND deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_competition_entries_published_featured
+ ON public.competition_entries(competition_id, is_featured, created_at DESC)
+ WHERE status = 'published' AND deleted_at IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_competition_votes_entry_id
+ ON public.competition_votes(competition_entry_id);
+
+CREATE INDEX IF NOT EXISTS idx_competition_votes_competition_created
+ ON public.competition_votes(competition_id, created_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_competition_votes_user_created
+ ON public.competition_votes(user_id, created_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_competition_judge_scores_competition_entry
+ ON public.competition_judge_scores(competition_id, competition_entry_id);
+
+CREATE INDEX IF NOT EXISTS idx_competition_judge_scores_judge_created
+ ON public.competition_judge_scores(judge_user_id, created_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_competition_entry_reports_entry
+ ON public.competition_entry_reports(competition_entry_id);
+
+ALTER TABLE public.competitions ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.competition_categories ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.competition_entries ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.competition_votes ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.competition_judge_scores ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.competition_entry_reports ENABLE ROW LEVEL SECURITY;
+
+ALTER TABLE public.competitions FORCE ROW LEVEL SECURITY;
+ALTER TABLE public.competition_categories FORCE ROW LEVEL SECURITY;
+ALTER TABLE public.competition_entries FORCE ROW LEVEL SECURITY;
+ALTER TABLE public.competition_votes FORCE ROW LEVEL SECURITY;
+ALTER TABLE public.competition_judge_scores FORCE ROW LEVEL SECURITY;
+ALTER TABLE public.competition_entry_reports FORCE ROW LEVEL SECURITY;
+
+DROP POLICY IF EXISTS "Competitions are viewable by everyone" ON public.competitions;
+DROP POLICY IF EXISTS "Admins can manage competitions" ON public.competitions;
+
+CREATE POLICY "Competitions are viewable by everyone"
+ON public.competitions
+FOR SELECT
+USING (true);
+
+CREATE POLICY "Admins can manage competitions"
+ON public.competitions
+FOR ALL
+USING (public.is_admin_or_moderator())
+WITH CHECK (public.is_admin_or_moderator());
+
+DROP POLICY IF EXISTS "Competition categories are viewable by everyone" ON public.competition_categories;
+DROP POLICY IF EXISTS "Admins can manage competition categories" ON public.competition_categories;
+
+CREATE POLICY "Competition categories are viewable by everyone"
+ON public.competition_categories
+FOR SELECT
+USING (true);
+
+CREATE POLICY "Admins can manage competition categories"
+ON public.competition_categories
+FOR ALL
+USING (public.is_admin_or_moderator())
+WITH CHECK (public.is_admin_or_moderator());
+
+DROP POLICY IF EXISTS "Competition entries are readable by public, owners, and admins" ON public.competition_entries;
+DROP POLICY IF EXISTS "Authenticated users can submit competition entries" ON public.competition_entries;
+DROP POLICY IF EXISTS "Owners can delete active competition entries" ON public.competition_entries;
+DROP POLICY IF EXISTS "Admins can moderate competition entries" ON public.competition_entries;
+
+CREATE POLICY "Competition entries are readable by public, owners, and admins"
+ON public.competition_entries
+FOR SELECT
+USING (
+ (
+ status = 'published'
+ AND deleted_at IS NULL
+ )
+ OR user_id = (SELECT auth.uid())
+ OR public.is_admin_or_moderator()
+);
+
+CREATE POLICY "Authenticated users can submit competition entries"
+ON public.competition_entries
+FOR INSERT
+TO authenticated
+WITH CHECK (
+ user_id = (SELECT auth.uid())
+ AND status = 'published'
+ AND deleted_at IS NULL
+ AND EXISTS (
+ SELECT 1
+ FROM public.competitions c
+ WHERE c.id = competition_entries.competition_id
+ AND c.status = 'active'
+ AND now() >= c.starts_at
+ AND now() < c.ends_at
+ )
+ AND EXISTS (
+ SELECT 1
+ FROM public.competition_categories cc
+ WHERE cc.id = competition_entries.category_id
+ AND cc.competition_id = competition_entries.competition_id
+ AND cc.is_active = true
+ )
+);
+
+CREATE POLICY "Owners can delete active competition entries"
+ON public.competition_entries
+FOR DELETE
+TO authenticated
+USING (
+ user_id = (SELECT auth.uid())
+ AND EXISTS (
+ SELECT 1
+ FROM public.competitions c
+ WHERE c.id = competition_entries.competition_id
+ AND c.status = 'active'
+ AND now() >= c.starts_at
+ AND now() < c.ends_at
+ )
+);
+
+CREATE POLICY "Admins can moderate competition entries"
+ON public.competition_entries
+FOR UPDATE
+TO authenticated
+USING (public.is_admin_or_moderator())
+WITH CHECK (public.is_admin_or_moderator());
+
+DROP POLICY IF EXISTS "Competition votes are viewable by everyone" ON public.competition_votes;
+DROP POLICY IF EXISTS "Authenticated users can manage own competition votes" ON public.competition_votes;
+
+CREATE POLICY "Competition votes are viewable by everyone"
+ON public.competition_votes
+FOR SELECT
+USING (true);
+
+CREATE POLICY "Authenticated users can insert own competition votes"
+ON public.competition_votes
+FOR INSERT
+TO authenticated
+WITH CHECK (
+ user_id = (SELECT auth.uid())
+ AND EXISTS (
+ SELECT 1
+ FROM public.competition_entries ce
+ JOIN public.competitions c ON c.id = ce.competition_id
+ WHERE ce.id = competition_votes.competition_entry_id
+ AND ce.competition_id = competition_votes.competition_id
+ AND ce.status = 'published'
+ AND ce.deleted_at IS NULL
+ AND ce.user_id <> (SELECT auth.uid())
+ AND c.status = 'active'
+ AND now() >= c.starts_at
+ AND now() < c.ends_at
+ )
+);
+
+CREATE POLICY "Authenticated users can delete own competition votes"
+ON public.competition_votes
+FOR DELETE
+TO authenticated
+USING (
+ user_id = (SELECT auth.uid())
+ AND EXISTS (
+ SELECT 1
+ FROM public.competitions c
+ WHERE c.id = competition_votes.competition_id
+ AND c.status = 'active'
+ AND now() >= c.starts_at
+ AND now() < c.ends_at
+ )
+);
+
+DROP POLICY IF EXISTS "Admins can manage competition judge scores" ON public.competition_judge_scores;
+
+CREATE POLICY "Admins can manage competition judge scores"
+ON public.competition_judge_scores
+FOR ALL
+TO authenticated
+USING (public.is_admin_or_moderator())
+WITH CHECK (public.is_admin_or_moderator());
+
+DROP POLICY IF EXISTS "Admins can review competition entry reports" ON public.competition_entry_reports;
+DROP POLICY IF EXISTS "Authenticated users can create own competition entry reports" ON public.competition_entry_reports;
+
+CREATE POLICY "Authenticated users can create own competition entry reports"
+ON public.competition_entry_reports
+FOR INSERT
+TO authenticated
+WITH CHECK (reporter_user_id = (SELECT auth.uid()));
+
+CREATE POLICY "Admins can review competition entry reports"
+ON public.competition_entry_reports
+FOR ALL
+TO authenticated
+USING (public.is_admin_or_moderator())
+WITH CHECK (public.is_admin_or_moderator());
+
+DROP TRIGGER IF EXISTS update_competitions_updated_at ON public.competitions;
+CREATE TRIGGER update_competitions_updated_at
+ BEFORE UPDATE ON public.competitions
+ FOR EACH ROW
+ EXECUTE FUNCTION public.update_updated_at_column();
+
+DROP TRIGGER IF EXISTS update_competition_entries_updated_at ON public.competition_entries;
+CREATE TRIGGER update_competition_entries_updated_at
+ BEFORE UPDATE ON public.competition_entries
+ FOR EACH ROW
+ EXECUTE FUNCTION public.update_updated_at_column();
+
+DROP TRIGGER IF EXISTS update_competition_judge_scores_updated_at ON public.competition_judge_scores;
+CREATE TRIGGER update_competition_judge_scores_updated_at
+ BEFORE UPDATE ON public.competition_judge_scores
+ FOR EACH ROW
+ EXECUTE FUNCTION public.update_updated_at_column();
diff --git a/scripts/24_extend_comments_for_competition_entries.sql b/scripts/24_extend_comments_for_competition_entries.sql
new file mode 100644
index 0000000..23d40a7
--- /dev/null
+++ b/scripts/24_extend_comments_for_competition_entries.sql
@@ -0,0 +1,56 @@
+-- Extend unified comments to support blog posts, projects, and competition entries.
+
+ALTER TABLE public.comments
+ ADD COLUMN IF NOT EXISTS post_id UUID REFERENCES public.posts(id) ON DELETE CASCADE;
+
+ALTER TABLE public.comments
+ ADD COLUMN IF NOT EXISTS competition_entry_id UUID REFERENCES public.competition_entries(id) ON DELETE CASCADE;
+
+ALTER TABLE public.comments
+ ALTER COLUMN project_id DROP NOT NULL;
+
+DROP INDEX IF EXISTS idx_comments_post_id;
+CREATE INDEX IF NOT EXISTS idx_comments_post_id
+ ON public.comments(post_id, created_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_comments_competition_entry_id
+ ON public.comments(competition_entry_id, created_at DESC);
+
+ALTER TABLE public.comments
+ DROP CONSTRAINT IF EXISTS comments_single_target_check;
+
+ALTER TABLE public.comments
+ ADD CONSTRAINT comments_single_target_check
+ CHECK (
+ (
+ CASE WHEN post_id IS NOT NULL THEN 1 ELSE 0 END
+ + CASE WHEN project_id IS NOT NULL THEN 1 ELSE 0 END
+ + CASE WHEN competition_entry_id IS NOT NULL THEN 1 ELSE 0 END
+ ) = 1
+ ) NOT VALID;
+
+ALTER TABLE public.comments
+ VALIDATE CONSTRAINT comments_single_target_check;
+
+DROP POLICY IF EXISTS "Anyone can insert comments" ON public.comments;
+DROP POLICY IF EXISTS "Secure comment insertion" ON public.comments;
+
+CREATE POLICY "Authenticated users can insert comments"
+ON public.comments
+FOR INSERT
+TO authenticated
+WITH CHECK (
+ user_id = (SELECT auth.uid())
+);
+
+CREATE POLICY "Guests can insert project comments"
+ON public.comments
+FOR INSERT
+TO anon
+WITH CHECK (
+ user_id IS NULL
+ AND author_name IS NOT NULL
+ AND project_id IS NOT NULL
+ AND post_id IS NULL
+ AND competition_entry_id IS NULL
+);
diff --git a/scripts/25_seed_mini_vibeathon.sql b/scripts/25_seed_mini_vibeathon.sql
new file mode 100644
index 0000000..d4290df
--- /dev/null
+++ b/scripts/25_seed_mini_vibeathon.sql
@@ -0,0 +1,97 @@
+-- Seed the active Mini Vibeathon competition and categories.
+
+INSERT INTO public.competitions (
+ slug,
+ title,
+ tagline,
+ description,
+ prize_text,
+ starts_at,
+ ends_at,
+ status,
+ rules_markdown,
+ judging_criteria_markdown,
+ faq_items,
+ timeline_items,
+ hero_primary_cta_label,
+ hero_secondary_cta_label,
+ judging_vote_weight,
+ judging_judge_weight
+)
+VALUES (
+ 'mini-vibeathon-2026',
+ 'Mini Vibeathon',
+ 'Dari ide ke demo, dalam 7 hari',
+ 'Kompetisi mini untuk builder Indonesia yang ingin mengubah ide menjadi demo yang bisa dipamerkan dalam waktu singkat.',
+ 'Cursor Credits',
+ '2026-05-09T00:00:00Z',
+ '2026-05-16T23:59:59Z',
+ 'active',
+ E'## Syarat utama\n- Kirim karya buatan sendiri.\n- Gunakan AI secara eksplisit dalam proses pembuatan.\n- Entry yang dipublikasikan tidak bisa diedit.\n- Maksimal 3 submission per peserta selama kompetisi aktif.\n- Entry yang melanggar aturan dapat disembunyikan atau didiskualifikasi.',
+ E'## Kriteria penilaian\n- Eksekusi dan kualitas demo.\n- Kreativitas ide dan implementasi.\n- Pemanfaatan AI dalam proses build.\n- UX dan presentasi.\n- Kelengkapan submission.',
+ '[
+ {"question":"Siapa saja yang boleh ikut?","answer":"Semua member komunitas yang sudah login ke VibeDev ID."},
+ {"question":"Boleh submit lebih dari satu entry?","answer":"Boleh, maksimal tiga entry selama kompetisi masih aktif."},
+ {"question":"Boleh edit submission setelah dikirim?","answer":"Tidak. Kamu hanya bisa menghapus entry sendiri saat kompetisi masih aktif lalu submit ulang."},
+ {"question":"Vote dibuka sampai kapan?","answer":"Vote dibuka selama kompetisi aktif dan membutuhkan akun yang login."}
+ ]'::jsonb,
+ '[
+ {"label":"Pendaftaran & submit dibuka","description":"Mulai kirim ide terbaikmu dan bangun demo secepat mungkin.","date":"2026-05-09"},
+ {"label":"Voting publik berjalan","description":"Ajak komunitas untuk mencoba dan vote entry favorit mereka.","date":"2026-05-09"},
+ {"label":"Batas akhir submission","description":"Submission dan voting ditutup di akhir hari terakhir kompetisi.","date":"2026-05-16"},
+ {"label":"Review juri & pengumuman","description":"Tim admin merapikan shortlist, juri memberi skor, lalu pemenang diumumkan.","date":"2026-05-17"}
+ ]'::jsonb,
+ 'Kirim Entry',
+ 'Lihat Semua Entry',
+ 0.30,
+ 0.70
+)
+ON CONFLICT (slug) DO UPDATE
+SET
+ title = EXCLUDED.title,
+ tagline = EXCLUDED.tagline,
+ description = EXCLUDED.description,
+ prize_text = EXCLUDED.prize_text,
+ starts_at = EXCLUDED.starts_at,
+ ends_at = EXCLUDED.ends_at,
+ status = EXCLUDED.status,
+ rules_markdown = EXCLUDED.rules_markdown,
+ judging_criteria_markdown = EXCLUDED.judging_criteria_markdown,
+ faq_items = EXCLUDED.faq_items,
+ timeline_items = EXCLUDED.timeline_items,
+ hero_primary_cta_label = EXCLUDED.hero_primary_cta_label,
+ hero_secondary_cta_label = EXCLUDED.hero_secondary_cta_label,
+ judging_vote_weight = EXCLUDED.judging_vote_weight,
+ judging_judge_weight = EXCLUDED.judging_judge_weight,
+ updated_at = now();
+
+WITH target_competition AS (
+ SELECT id
+ FROM public.competitions
+ WHERE slug = 'mini-vibeathon-2026'
+)
+INSERT INTO public.competition_categories (competition_id, slug, label, description, sort_order)
+SELECT
+ target_competition.id,
+ seeded.slug,
+ seeded.label,
+ seeded.description,
+ seeded.sort_order
+FROM target_competition
+CROSS JOIN (
+ VALUES
+ ('ai-apps', 'AI Apps', 'Produk AI untuk use case nyata.', 1),
+ ('developer-tools', 'Developer Tools', 'Tooling untuk bantu workflow developer.', 2),
+ ('productivity', 'Productivity', 'App untuk bantu kerja lebih cepat.', 3),
+ ('education', 'Education', 'Produk belajar, latihan, atau simulasi.', 4),
+ ('automation', 'Automation', 'Workflow, bot, atau agent automation.', 5),
+ ('creative-tools', 'Creative Tools', 'Tool kreatif untuk desain, konten, atau eksperimen.', 6),
+ ('saas', 'SaaS', 'Software-as-a-Service untuk masalah spesifik.', 7),
+ ('consumer', 'Consumer', 'Produk consumer untuk kebutuhan harian.', 8)
+) AS seeded(slug, label, description, sort_order)
+ON CONFLICT (competition_id, slug) DO UPDATE
+SET
+ label = EXCLUDED.label,
+ description = EXCLUDED.description,
+ sort_order = EXCLUDED.sort_order,
+ is_active = true;
diff --git a/types/comments.ts b/types/comments.ts
index 03348c5..941c593 100644
--- a/types/comments.ts
+++ b/types/comments.ts
@@ -52,17 +52,17 @@ export interface CommentApiResponse {
}
/**
- * Entity type for comments (blog post or project)
+ * Entity type for comments (blog post, project, or competition entry)
*/
-export type CommentEntityType = 'post' | 'project'
+export type CommentEntityType = 'post' | 'project' | 'competition'
/**
* Props for unified CommentSection component
*/
export interface CommentSectionProps {
- /** Entity type: 'post' for blog, 'project' for projects */
+ /** Entity type: 'post' for blog, 'project' for projects, 'competition' for competition entries */
entityType: CommentEntityType
- /** Entity ID (post_id or project_id) */
+ /** Entity ID (post_id, project_id, or competition_entry_id) */
entityId: string
/** Pre-fetched comments from server */
initialComments: Comment[]
diff --git a/types/competition.ts b/types/competition.ts
new file mode 100644
index 0000000..2a97450
--- /dev/null
+++ b/types/competition.ts
@@ -0,0 +1,98 @@
+export type CompetitionStatus = 'draft' | 'active' | 'closed' | 'archived'
+
+export type CompetitionEntryStatus = 'published' | 'hidden' | 'disqualified'
+
+export type CompetitionSort = 'newest' | 'oldest' | 'top'
+
+export interface CompetitionFaqItem {
+ question: string
+ answer: string
+}
+
+export interface CompetitionTimelineItem {
+ label: string
+ description: string
+ date: string
+}
+
+export interface CompetitionCategory {
+ id: string
+ competitionId: string
+ slug: string
+ label: string
+ description: string | null
+ sortOrder: number
+ isActive: boolean
+}
+
+export interface Competition {
+ id: string
+ slug: string
+ title: string
+ tagline: string
+ description: string
+ prizeText: string
+ startsAt: string
+ endsAt: string
+ status: CompetitionStatus
+ rulesMarkdown: string
+ judgingCriteriaMarkdown: string
+ faqItems: CompetitionFaqItem[]
+ timelineItems: CompetitionTimelineItem[]
+ heroPrimaryCtaLabel: string | null
+ heroSecondaryCtaLabel: string | null
+ judgingVoteWeight: number
+ judgingJudgeWeight: number
+ createdAt: string
+ updatedAt: string
+}
+
+export interface CompetitionEntryAuthor {
+ id: string
+ displayName: string
+ username: string
+ avatarUrl: string | null
+ role: number | null
+}
+
+export interface CompetitionEntrySummary {
+ id: string
+ competitionId: string
+ categoryId: string
+ userId: string
+ slug: string
+ title: string
+ tagline: string
+ thumbnailUrl: string
+ voteCount: number
+ commentCount: number
+ status: CompetitionEntryStatus
+ isFeatured: boolean
+ isFinalist: boolean
+ isWinner: boolean
+ commentsLocked: boolean
+ submittedAt: string
+ createdAt: string
+ category: CompetitionCategory | null
+ author: CompetitionEntryAuthor | null
+}
+
+export interface CompetitionEntryDetail extends CompetitionEntrySummary {
+ description: string
+ processSummary: string
+ aiToolsUsed: string[]
+ techStacks: string[]
+ demoUrl: string
+ repoUrl: string
+ thumbnailKey: string | null
+ galleryUrls: string[]
+ galleryKeys: string[]
+ videoUrl: string | null
+ competition: Competition
+ hasVoted: boolean
+}
+
+export interface CompetitionEntriesResult {
+ competition: Competition
+ entries: CompetitionEntrySummary[]
+}