Skip to content

Commit 6f74985

Browse files
authored
fix: wire brief submission, brand briefs page, and creator profile to real API (#18)
- SubmitBriefForm: replace mock agencies (test_agency_001 only), add fetch() to POST /api/v1/briefs, show toast on success/failure - GET /api/v1/briefs: support brand_manager role via fallback auth (agency first, then brand on 403) - app/(brand)/brand/briefs/page.tsx: new Server Component listing briefs for the authenticated brand manager with empty state and status badges - GET/PATCH /api/v1/profile: new endpoint returning/updating Creator record for authenticated creator; handle is immutable - lib/validations/creator.ts: UpdateCreatorProfileSchema (Zod) - creator/profile/page.tsx: replace mockCreators with serverFetch - CreatorProfileEditor: replace MockCreator import with inline type, wire handleSave to PATCH /api/v1/profile with toast feedback
1 parent b0701d4 commit 6f74985

File tree

8 files changed

+338
-20
lines changed

8 files changed

+338
-20
lines changed

app/(brand)/brand/briefs/page.tsx

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import Link from 'next/link'
2+
import { serverFetch } from '@/lib/api'
3+
import { Badge } from '@/components/ui/badge'
4+
import {
5+
Table,
6+
TableBody,
7+
TableCell,
8+
TableHead,
9+
TableHeader,
10+
TableRow,
11+
} from '@/components/ui/table'
12+
13+
type Brief = {
14+
id: string
15+
title: string
16+
platform: string | null
17+
budget: number | null
18+
status: string
19+
createdAt: string
20+
}
21+
22+
function statusBadge(status: string) {
23+
switch (status) {
24+
case 'NEW':
25+
return <Badge className="bg-blue-500/15 text-blue-400 border-blue-500/20 hover:bg-blue-500/15 text-xs">NEW</Badge>
26+
case 'REVIEWED':
27+
return <Badge variant="outline" className="text-xs">REVIEWED</Badge>
28+
case 'CONVERTED':
29+
return <Badge className="bg-green-500/15 text-green-400 border-green-500/20 hover:bg-green-500/15 text-xs">CONVERTED</Badge>
30+
case 'DECLINED':
31+
return <Badge variant="destructive" className="text-xs">DECLINED</Badge>
32+
default:
33+
return <Badge variant="outline" className="text-xs">{status}</Badge>
34+
}
35+
}
36+
37+
export default async function BrandBriefsPage() {
38+
const res = await serverFetch('/api/v1/briefs', { cache: 'no-store' })
39+
const { data: briefs } = (await res.json()) as { data: Brief[] | null; error: string | null }
40+
const list = briefs ?? []
41+
42+
if (list.length === 0) {
43+
return (
44+
<div className="p-6">
45+
<h1 className="text-2xl font-bold tracking-tight mb-6">My Briefs</h1>
46+
<div className="flex flex-col items-center justify-center py-20 text-center">
47+
<p className="text-muted-foreground text-sm mb-4">You haven&apos;t submitted any briefs yet.</p>
48+
<Link href="/brand/briefs/new" className="text-sm font-medium underline underline-offset-4">
49+
Submit your first brief
50+
</Link>
51+
</div>
52+
</div>
53+
)
54+
}
55+
56+
return (
57+
<div className="p-6">
58+
<h1 className="text-2xl font-bold tracking-tight mb-6">My Briefs</h1>
59+
<div className="rounded-lg border border-border">
60+
<Table>
61+
<TableHeader>
62+
<TableRow>
63+
<TableHead>Title</TableHead>
64+
<TableHead>Platform</TableHead>
65+
<TableHead>Budget</TableHead>
66+
<TableHead>Status</TableHead>
67+
<TableHead>Submitted</TableHead>
68+
</TableRow>
69+
</TableHeader>
70+
<TableBody>
71+
{list.map(brief => (
72+
<TableRow key={brief.id}>
73+
<TableCell>
74+
<span className="font-medium text-sm">{brief.title}</span>
75+
</TableCell>
76+
<TableCell>
77+
<span className="text-sm">{brief.platform ?? '—'}</span>
78+
</TableCell>
79+
<TableCell>
80+
<span className="font-mono text-sm tabular-nums">
81+
{brief.budget != null ? `$${brief.budget.toLocaleString()}` : '—'}
82+
</span>
83+
</TableCell>
84+
<TableCell>{statusBadge(brief.status)}</TableCell>
85+
<TableCell>
86+
<span className="font-mono text-xs text-muted-foreground tabular-nums">
87+
{new Date(brief.createdAt).toLocaleDateString('en-US', {
88+
month: 'short',
89+
day: 'numeric',
90+
year: 'numeric',
91+
})}
92+
</span>
93+
</TableCell>
94+
</TableRow>
95+
))}
96+
</TableBody>
97+
</Table>
98+
</div>
99+
</div>
100+
)
101+
}

app/(creator)/creator/profile/page.tsx

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,31 @@
1-
import { mockCreators } from '@/lib/mock/creators'
1+
import { serverFetch } from '@/lib/api'
22
import { CreatorProfileEditor } from '@/components/creator/CreatorProfileEditor'
33

4-
export default function CreatorProfilePage() {
5-
const creator = mockCreators.find(c => c.id === 'creator_001')!
4+
type CreatorProfile = {
5+
name: string
6+
handle: string
7+
bio: string | null
8+
avatarUrl: string | null
9+
platforms: string[]
10+
nicheTags: string[]
11+
followerCount: number | null
12+
engagementRate: number | null
13+
isPublic: boolean
14+
}
15+
16+
export default async function CreatorProfilePage() {
17+
const res = await serverFetch('/api/v1/profile', { cache: 'no-store' })
18+
const { data: creator } = (await res.json()) as { data: CreatorProfile | null; error: string | null }
19+
20+
if (!creator) {
21+
return (
22+
<div className="p-6 max-w-2xl">
23+
<h1 className="text-2xl font-bold tracking-tight mb-6">My Profile</h1>
24+
<p className="text-muted-foreground text-sm">Creator profile not found.</p>
25+
</div>
26+
)
27+
}
28+
629
return (
730
<div className="p-6 max-w-2xl">
831
<h1 className="text-2xl font-bold tracking-tight mb-6">My Profile</h1>

app/api/v1/briefs/route.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,38 @@ import NewBriefEmail from '@/emails/new-brief'
99
import React from 'react'
1010

1111
export async function GET(req: NextRequest) {
12-
const authResult = await requireAgencyAuth()
13-
if (!authResult.ok) return authResult.response
14-
const { userId: agencyClerkId } = authResult
15-
1612
const status = req.nextUrl.searchParams.get('status') ?? undefined
1713

14+
// Try agency auth first
15+
const agencyAuth = await requireAgencyAuth()
16+
if (agencyAuth.ok) {
17+
const { userId: agencyClerkId } = agencyAuth
18+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
19+
const where: any = { agencyClerkId }
20+
if (status) where.status = status
21+
const briefs = await db.brief.findMany({
22+
where,
23+
include: { creator: { select: { id: true, name: true, handle: true, avatarUrl: true } } },
24+
orderBy: { createdAt: 'desc' },
25+
})
26+
return ok(briefs.map(serializeBrief))
27+
}
28+
29+
// Return 401 immediately if not logged in; only try brand auth on 403 (wrong role)
30+
if (agencyAuth.response.status !== 403) return agencyAuth.response
31+
32+
const brandAuth = await requireBrandAuth()
33+
if (!brandAuth.ok) return brandAuth.response
34+
const { userId: brandManagerClerkId } = brandAuth
35+
1836
// eslint-disable-next-line @typescript-eslint/no-explicit-any
19-
const where: any = { agencyClerkId }
37+
const where: any = { brandManagerClerkId }
2038
if (status) where.status = status
21-
2239
const briefs = await db.brief.findMany({
2340
where,
2441
include: { creator: { select: { id: true, name: true, handle: true, avatarUrl: true } } },
2542
orderBy: { createdAt: 'desc' },
2643
})
27-
2844
return ok(briefs.map(serializeBrief))
2945
}
3046

app/api/v1/profile/route.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { NextRequest } from 'next/server'
2+
import { db } from '@/lib/db'
3+
import { ok, badRequest, notFound, unprocessable } from '@/lib/api-response'
4+
import { requireCreatorAuth } from '@/lib/auth'
5+
import { UpdateCreatorProfileSchema } from '@/lib/validations/creator'
6+
7+
export async function GET() {
8+
const authResult = await requireCreatorAuth()
9+
if (!authResult.ok) return authResult.response
10+
const { userId: clerkId } = authResult
11+
12+
const creator = await db.creator.findUnique({ where: { clerkId } })
13+
if (!creator) return notFound('Creator profile not found')
14+
15+
return ok(serializeCreator(creator))
16+
}
17+
18+
export async function PATCH(req: NextRequest) {
19+
const authResult = await requireCreatorAuth()
20+
if (!authResult.ok) return authResult.response
21+
const { userId: clerkId } = authResult
22+
23+
let body: unknown
24+
try {
25+
body = await req.json()
26+
} catch {
27+
return badRequest('Invalid JSON body')
28+
}
29+
30+
const parse = UpdateCreatorProfileSchema.safeParse(body)
31+
if (!parse.success) return unprocessable(parse.error.issues[0]?.message ?? 'Validation error')
32+
33+
const creator = await db.creator.findUnique({ where: { clerkId } })
34+
if (!creator) return notFound('Creator profile not found')
35+
36+
const { name, bio, platforms, nicheTags, followerCount, engagementRate, isPublic } = parse.data
37+
38+
const updated = await db.creator.update({
39+
where: { clerkId },
40+
data: {
41+
...(name !== undefined && { name }),
42+
...(bio !== undefined && { bio }),
43+
...(platforms !== undefined && { platforms }),
44+
...(nicheTags !== undefined && { nicheTags }),
45+
...(followerCount !== undefined && { followerCount }),
46+
...(engagementRate !== undefined && { engagementRate }),
47+
...(isPublic !== undefined && { isPublic }),
48+
},
49+
})
50+
51+
return ok(serializeCreator(updated))
52+
}
53+
54+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
55+
function serializeCreator(c: any) {
56+
return {
57+
...c,
58+
engagementRate: c.engagementRate != null ? Number(c.engagementRate) : null,
59+
}
60+
}

components/briefs/SubmitBriefForm.tsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useState } from 'react'
44
import { useForm } from 'react-hook-form'
5+
import { toast } from 'sonner'
56
import { CheckCircle } from 'lucide-react'
67
import { Button } from '@/components/ui/button'
78
import { Input } from '@/components/ui/input'
@@ -17,9 +18,7 @@ import {
1718
import { Card } from '@/components/ui/card'
1819

1920
const MOCK_AGENCIES = [
20-
{ id: 'agency_001', name: 'Apex Talent Group' },
21-
{ id: 'agency_002', name: 'Nova Creator Agency' },
22-
{ id: 'agency_003', name: 'Spark Media Management' },
21+
{ id: 'test_agency_001', name: 'Apex Talent Group' },
2322
]
2423

2524
const PLATFORMS = [
@@ -79,10 +78,32 @@ export function SubmitBriefForm() {
7978
return errs
8079
}
8180

82-
function onSubmit(data: BriefFormValues) {
81+
async function onSubmit(data: BriefFormValues) {
8382
const errs = validate(data)
8483
if (Object.keys(errs).length > 0) return
85-
setSubmitted(true)
84+
85+
try {
86+
const res = await fetch('/api/v1/briefs', {
87+
method: 'POST',
88+
headers: { 'Content-Type': 'application/json' },
89+
body: JSON.stringify({
90+
agencyClerkId: data.agencyId,
91+
title: data.title,
92+
description: data.description,
93+
platform: data.platform || undefined,
94+
niche: data.niche || undefined,
95+
budget: data.budget ? Number(data.budget) : undefined,
96+
}),
97+
})
98+
const json = await res.json()
99+
if (!res.ok) {
100+
toast.error(json.error ?? 'Failed to submit brief')
101+
return
102+
}
103+
setSubmitted(true)
104+
} catch {
105+
toast.error('Failed to submit brief')
106+
}
86107
}
87108

88109
if (submitted) {

components/creator/CreatorProfileEditor.tsx

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,26 @@ import { Textarea } from '@/components/ui/textarea'
1010
import { Checkbox } from '@/components/ui/checkbox'
1111
import { Badge } from '@/components/ui/badge'
1212
import { Switch } from '@/components/ui/switch'
13-
import type { MockCreator } from '@/lib/mock/creators'
13+
type CreatorProfile = {
14+
name: string
15+
handle: string
16+
bio: string | null
17+
avatarUrl: string | null
18+
platforms: string[]
19+
nicheTags: string[]
20+
followerCount: number | null
21+
engagementRate: number | null
22+
isPublic: boolean
23+
}
1424

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

1727
interface CreatorProfileEditorProps {
18-
initialCreator: MockCreator
28+
initialCreator: CreatorProfile
1929
}
2030

2131
export function CreatorProfileEditor({ initialCreator }: CreatorProfileEditorProps) {
22-
const [creator, setCreator] = useState<MockCreator>(initialCreator)
32+
const [creator, setCreator] = useState<CreatorProfile>(initialCreator)
2333
const [tagInput, setTagInput] = useState('')
2434

2535
const initials = creator.name
@@ -56,8 +66,30 @@ export function CreatorProfileEditor({ initialCreator }: CreatorProfileEditorPro
5666
}))
5767
}
5868

59-
function handleSave() {
60-
toast.success('Profile saved')
69+
async function handleSave() {
70+
try {
71+
const res = await fetch('/api/v1/profile', {
72+
method: 'PATCH',
73+
headers: { 'Content-Type': 'application/json' },
74+
body: JSON.stringify({
75+
name: creator.name,
76+
bio: creator.bio,
77+
platforms: creator.platforms,
78+
nicheTags: creator.nicheTags,
79+
followerCount: creator.followerCount,
80+
engagementRate: creator.engagementRate,
81+
isPublic: creator.isPublic,
82+
}),
83+
})
84+
if (!res.ok) {
85+
const json = await res.json()
86+
toast.error(json.error ?? 'Failed to save profile')
87+
return
88+
}
89+
toast.success('Profile saved')
90+
} catch {
91+
toast.error('Failed to save profile')
92+
}
6193
}
6294

6395
return (

0 commit comments

Comments
 (0)