Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions app/(brand)/brand/briefs/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import Link from 'next/link'
import { serverFetch } from '@/lib/api'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'

type Brief = {
id: string
title: string
platform: string | null
budget: number | null
status: string
createdAt: string
}

function statusBadge(status: string) {
switch (status) {
case 'NEW':
return <Badge className="bg-blue-500/15 text-blue-400 border-blue-500/20 hover:bg-blue-500/15 text-xs">NEW</Badge>
case 'REVIEWED':
return <Badge variant="outline" className="text-xs">REVIEWED</Badge>
case 'CONVERTED':
return <Badge className="bg-green-500/15 text-green-400 border-green-500/20 hover:bg-green-500/15 text-xs">CONVERTED</Badge>
case 'DECLINED':
return <Badge variant="destructive" className="text-xs">DECLINED</Badge>
default:
return <Badge variant="outline" className="text-xs">{status}</Badge>
}
}

export default async function BrandBriefsPage() {
const res = await serverFetch('/api/v1/briefs', { cache: 'no-store' })
const { data: briefs } = (await res.json()) as { data: Brief[] | null; error: string | null }
const list = briefs ?? []

if (list.length === 0) {
return (
<div className="p-6">
<h1 className="text-2xl font-bold tracking-tight mb-6">My Briefs</h1>
<div className="flex flex-col items-center justify-center py-20 text-center">
<p className="text-muted-foreground text-sm mb-4">You haven&apos;t submitted any briefs yet.</p>
<Link href="/brand/briefs/new" className="text-sm font-medium underline underline-offset-4">
Submit your first brief
</Link>
</div>
</div>
)
}

return (
<div className="p-6">
<h1 className="text-2xl font-bold tracking-tight mb-6">My Briefs</h1>
<div className="rounded-lg border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Platform</TableHead>
<TableHead>Budget</TableHead>
<TableHead>Status</TableHead>
<TableHead>Submitted</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map(brief => (
<TableRow key={brief.id}>
<TableCell>
<span className="font-medium text-sm">{brief.title}</span>
</TableCell>
<TableCell>
<span className="text-sm">{brief.platform ?? '—'}</span>
</TableCell>
<TableCell>
<span className="font-mono text-sm tabular-nums">
{brief.budget != null ? `$${brief.budget.toLocaleString()}` : '—'}
</span>
</TableCell>
<TableCell>{statusBadge(brief.status)}</TableCell>
<TableCell>
<span className="font-mono text-xs text-muted-foreground tabular-nums">
{new Date(brief.createdAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
29 changes: 26 additions & 3 deletions app/(creator)/creator/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
import { mockCreators } from '@/lib/mock/creators'
import { serverFetch } from '@/lib/api'
import { CreatorProfileEditor } from '@/components/creator/CreatorProfileEditor'

export default function CreatorProfilePage() {
const creator = mockCreators.find(c => c.id === 'creator_001')!
type CreatorProfile = {
name: string
handle: string
bio: string | null
avatarUrl: string | null
platforms: string[]
nicheTags: string[]
followerCount: number | null
engagementRate: number | null
isPublic: boolean
}

export default async function CreatorProfilePage() {
const res = await serverFetch('/api/v1/profile', { cache: 'no-store' })
const { data: creator } = (await res.json()) as { data: CreatorProfile | null; error: string | null }

if (!creator) {
return (
<div className="p-6 max-w-2xl">
<h1 className="text-2xl font-bold tracking-tight mb-6">My Profile</h1>
<p className="text-muted-foreground text-sm">Creator profile not found.</p>
</div>
)
}

return (
<div className="p-6 max-w-2xl">
<h1 className="text-2xl font-bold tracking-tight mb-6">My Profile</h1>
Expand Down
30 changes: 23 additions & 7 deletions app/api/v1/briefs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,38 @@ import NewBriefEmail from '@/emails/new-brief'
import React from 'react'

export async function GET(req: NextRequest) {
const authResult = await requireAgencyAuth()
if (!authResult.ok) return authResult.response
const { userId: agencyClerkId } = authResult

const status = req.nextUrl.searchParams.get('status') ?? undefined

// Try agency auth first
const agencyAuth = await requireAgencyAuth()
if (agencyAuth.ok) {
const { userId: agencyClerkId } = agencyAuth
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = { agencyClerkId }
if (status) where.status = status
const briefs = await db.brief.findMany({
where,
include: { creator: { select: { id: true, name: true, handle: true, avatarUrl: true } } },
orderBy: { createdAt: 'desc' },
})
return ok(briefs.map(serializeBrief))
}

// Return 401 immediately if not logged in; only try brand auth on 403 (wrong role)
if (agencyAuth.response.status !== 403) return agencyAuth.response

const brandAuth = await requireBrandAuth()
if (!brandAuth.ok) return brandAuth.response
const { userId: brandManagerClerkId } = brandAuth

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = { agencyClerkId }
const where: any = { brandManagerClerkId }
if (status) where.status = status

const briefs = await db.brief.findMany({
where,
include: { creator: { select: { id: true, name: true, handle: true, avatarUrl: true } } },
orderBy: { createdAt: 'desc' },
})

return ok(briefs.map(serializeBrief))
}

Expand Down
60 changes: 60 additions & 0 deletions app/api/v1/profile/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { NextRequest } from 'next/server'
import { db } from '@/lib/db'
import { ok, badRequest, notFound, unprocessable } from '@/lib/api-response'
import { requireCreatorAuth } from '@/lib/auth'
import { UpdateCreatorProfileSchema } from '@/lib/validations/creator'

export async function GET() {
const authResult = await requireCreatorAuth()
if (!authResult.ok) return authResult.response
const { userId: clerkId } = authResult

const creator = await db.creator.findUnique({ where: { clerkId } })
if (!creator) return notFound('Creator profile not found')

return ok(serializeCreator(creator))
}

export async function PATCH(req: NextRequest) {
const authResult = await requireCreatorAuth()
if (!authResult.ok) return authResult.response
const { userId: clerkId } = authResult

let body: unknown
try {
body = await req.json()
} catch {
return badRequest('Invalid JSON body')
}

const parse = UpdateCreatorProfileSchema.safeParse(body)
if (!parse.success) return unprocessable(parse.error.issues[0]?.message ?? 'Validation error')

const creator = await db.creator.findUnique({ where: { clerkId } })
if (!creator) return notFound('Creator profile not found')

const { name, bio, platforms, nicheTags, followerCount, engagementRate, isPublic } = parse.data

const updated = await db.creator.update({
where: { clerkId },
data: {
...(name !== undefined && { name }),
...(bio !== undefined && { bio }),
...(platforms !== undefined && { platforms }),
...(nicheTags !== undefined && { nicheTags }),
...(followerCount !== undefined && { followerCount }),
...(engagementRate !== undefined && { engagementRate }),
...(isPublic !== undefined && { isPublic }),
},
})

return ok(serializeCreator(updated))
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function serializeCreator(c: any) {
return {
...c,
engagementRate: c.engagementRate != null ? Number(c.engagementRate) : null,
}
}
31 changes: 26 additions & 5 deletions components/briefs/SubmitBriefForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { CheckCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
Expand All @@ -17,9 +18,7 @@ import {
import { Card } from '@/components/ui/card'

const MOCK_AGENCIES = [
{ id: 'agency_001', name: 'Apex Talent Group' },
{ id: 'agency_002', name: 'Nova Creator Agency' },
{ id: 'agency_003', name: 'Spark Media Management' },
{ id: 'test_agency_001', name: 'Apex Talent Group' },
]

const PLATFORMS = [
Expand Down Expand Up @@ -79,10 +78,32 @@ export function SubmitBriefForm() {
return errs
}

function onSubmit(data: BriefFormValues) {
async function onSubmit(data: BriefFormValues) {
const errs = validate(data)
if (Object.keys(errs).length > 0) return
setSubmitted(true)

try {
const res = await fetch('/api/v1/briefs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agencyClerkId: data.agencyId,
title: data.title,
description: data.description,
platform: data.platform || undefined,
niche: data.niche || undefined,
budget: data.budget ? Number(data.budget) : undefined,
}),
})
const json = await res.json()
if (!res.ok) {
toast.error(json.error ?? 'Failed to submit brief')
return
}
setSubmitted(true)
} catch {
toast.error('Failed to submit brief')
}
}

if (submitted) {
Expand Down
42 changes: 37 additions & 5 deletions components/creator/CreatorProfileEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,26 @@ import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import type { MockCreator } from '@/lib/mock/creators'
type CreatorProfile = {
name: string
handle: string
bio: string | null
avatarUrl: string | null
platforms: string[]
nicheTags: string[]
followerCount: number | null
engagementRate: number | null
isPublic: boolean
}

const ALL_PLATFORMS = ['Instagram', 'TikTok', 'YouTube', 'Twitter', 'LinkedIn', 'Pinterest'] as const

interface CreatorProfileEditorProps {
initialCreator: MockCreator
initialCreator: CreatorProfile
}

export function CreatorProfileEditor({ initialCreator }: CreatorProfileEditorProps) {
const [creator, setCreator] = useState<MockCreator>(initialCreator)
const [creator, setCreator] = useState<CreatorProfile>(initialCreator)
const [tagInput, setTagInput] = useState('')

const initials = creator.name
Expand Down Expand Up @@ -56,8 +66,30 @@ export function CreatorProfileEditor({ initialCreator }: CreatorProfileEditorPro
}))
}

function handleSave() {
toast.success('Profile saved')
async function handleSave() {
try {
const res = await fetch('/api/v1/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: creator.name,
bio: creator.bio,
platforms: creator.platforms,
nicheTags: creator.nicheTags,
followerCount: creator.followerCount,
engagementRate: creator.engagementRate,
isPublic: creator.isPublic,
}),
})
if (!res.ok) {
const json = await res.json()
toast.error(json.error ?? 'Failed to save profile')
return
}
toast.success('Profile saved')
} catch {
toast.error('Failed to save profile')
}
}

return (
Expand Down
Loading
Loading