1- import type { Metadata , ResolvingMetadata } from "next" ;
2- import type { PortableTextBlock } from "next-sanity" ;
3- import { notFound } from "next/navigation" ;
1+ 'use client' ;
42
5- import PortableText from "@/components/portable-text" ;
3+ import { z } from "zod" ;
4+ import { useForm } from "react-hook-form" ;
5+ import { zodResolver } from "@hookform/resolvers/zod" ;
6+ import { Button } from "@/components/ui/button" ;
7+ import {
8+ Form ,
9+ FormControl ,
10+ FormDescription ,
11+ FormField ,
12+ FormItem ,
13+ FormLabel ,
14+ FormMessage ,
15+ } from "@/components/ui/form" ;
16+ import { Input } from "@/components/ui/input" ;
17+ import {
18+ Select ,
19+ SelectContent ,
20+ SelectItem ,
21+ SelectTrigger ,
22+ SelectValue ,
23+ } from "@/components/ui/select" ;
24+ import { Textarea } from "@/components/ui/textarea" ;
25+ import { Card , CardContent , CardHeader , CardTitle } from "@/components/ui/card" ;
26+ import { CloudflareTurnstileWidget } from "@/components/cloudflare-turnstile" ;
627
7- import type { PageQueryResult } from "@/sanity/types" ;
8- import { sanityFetch } from "@/sanity/lib/live" ;
9- import { pageQuery } from "@/sanity/lib/queries" ;
10- import { resolveOpenGraphImage } from "@/sanity/lib/utils" ;
11- import { BreadcrumbLinks } from "@/components/breadrumb-links" ;
12- import CoverImage from "@/components/cover-image" ;
28+ const sponsorshipTiers = [
29+ {
30+ name : "Dedicated Video" ,
31+ price : "$4,000 USD" ,
32+ description :
33+ "A full video dedicated to your product or service. Includes a permanent logo and link on our sponsors page." ,
34+ value : "dedicated-video" ,
35+ } ,
36+ {
37+ name : "Integrated Mid-Roll Ad (60 seconds)" ,
38+ price : "$1,800 USD" ,
39+ description :
40+ "A 60-second ad integrated into the middle of a video. Includes a permanent logo and link on our sponsors page." ,
41+ value : "mid-roll-ad" ,
42+ } ,
43+ {
44+ name : "Quick Shout-Out (30 seconds)" ,
45+ price : "$900 USD" ,
46+ description :
47+ "A 30-second shout-out at the beginning of a video. Includes a permanent logo and link on our sponsors page." ,
48+ value : "shout-out" ,
49+ } ,
50+ {
51+ name : "Blog Post / Newsletter Sponsorship" ,
52+ price : "$500 USD" ,
53+ description :
54+ "Sponsor a blog post or our weekly newsletter. Your logo and a link will be featured." ,
55+ value : "blog-newsletter" ,
56+ } ,
57+ {
58+ name : "Video Series (Custom Pricing)" ,
59+ price : "Contact for pricing" ,
60+ description :
61+ "Sponsor a whole series of videos. Contact us for custom pricing and packages." ,
62+ value : "video-series" ,
63+ } ,
64+ ] ;
1365
14- type Props = {
15- params : Promise < { slug : string } > ;
16- searchParams : Promise < { [ key : string ] : string | string [ ] | undefined } > ;
17- } ;
66+ const formSchema = z . object ( {
67+ fullName : z . string ( ) . min ( 2 , {
68+ message : "Full name must be at least 2 characters." ,
69+ } ) ,
70+ email : z . string ( ) . email ( {
71+ message : "Please enter a valid email address." ,
72+ } ) ,
73+ companyName : z . string ( ) . optional ( ) ,
74+ sponsorshipTier : z . string ( ) . min ( 1 , "Please select a sponsorship tier." ) ,
75+ message : z . string ( ) . optional ( ) ,
76+ honeypot : z . string ( ) . optional ( ) , // Honeypot field
77+ "cf-turnstile-response" : z . string ( ) . min ( 1 , { message : "Please complete the CAPTCHA." } ) ,
78+ } ) ;
1879
19- export async function generateMetadata (
20- { params, searchParams } : Props ,
21- parent : ResolvingMetadata ,
22- ) : Promise < Metadata > {
23- const page = (
24- await sanityFetch ( {
25- query : pageQuery ,
26- params : {
27- slug : "sponsorships" ,
28- } ,
29- stega : false ,
30- } )
31- ) . data as PageQueryResult ;
32- const previousImages = ( await parent ) . openGraph ?. images || [ ] ;
33- const ogImage = resolveOpenGraphImage ( page ?. coverImage ) ;
34-
35- return {
36- title : page ?. title ,
37- description : page ?. excerpt ,
38- openGraph : {
39- images : ogImage ? ogImage : previousImages ,
80+ export default function SponsorshipsPage ( ) {
81+ const form = useForm < z . infer < typeof formSchema > > ( {
82+ resolver : zodResolver ( formSchema ) ,
83+ defaultValues : {
84+ fullName : "" ,
85+ email : "" ,
86+ companyName : "" ,
87+ sponsorshipTier : undefined ,
88+ message : "" ,
89+ honeypot : "" ,
90+ "cf-turnstile-response" : "" ,
4091 } ,
41- } satisfies Metadata ;
42- }
92+ } ) ;
93+
94+ async function onSubmit ( values : z . infer < typeof formSchema > ) {
95+ // Honeypot check
96+ if ( values . honeypot ) {
97+ console . log ( "Honeypot field filled out, ignoring submission." ) ;
98+ return ;
99+ }
100+ // TODO: Add Cloudflare Turnstile verification here
43101
44- export default async function SponsorshipsPage ( {
45- params,
46- searchParams,
47- } : Props ) {
48- const page = (
49- await sanityFetch ( {
50- query : pageQuery ,
51- params : {
52- slug : "sponsorships" ,
102+ const response = await fetch ( "/api/sponsorship" , {
103+ method : "POST" ,
104+ headers : {
105+ "Content-Type" : "application/json" ,
53106 } ,
54- stega : false ,
55- } )
56- ) . data as PageQueryResult ;
107+ // We only send the values that the API route expects, excluding the turnstile response
108+ // which is verified on the server from the body.
109+ // body: JSON.stringify({
110+ // ...values,
111+ // // The turnstile token is automatically included in the form data
112+ // // and will be sent to the server.
113+ // }),
114+ body : JSON . stringify ( values ) ,
115+ } ) ;
57116
58- if ( ! page ?. _id ) {
59- return notFound ( ) ;
117+ if ( response . ok ) {
118+ form . reset ( ) ;
119+ // TODO: Show a success message to the user
120+ console . log ( "Sponsorship request submitted successfully!" ) ;
121+ } else {
122+ // TODO: Show an error message to the user
123+ console . error ( "Failed to submit sponsorship request." ) ;
124+ }
60125 }
61126
62127 return (
63128 < div className = "container px-5 mx-auto" >
64129 < div className = "w-full flex flex-col gap-4 md:gap-8 my-8 md:my-12" >
65- < div className = "flex flex-col gap-2 md:gap-" >
66- < h1 className = "text-xl font-bold leading-tight tracking-tighter text-balance md:text-2xl md:leading-none lg:text-4xl " >
67- { page . title }
130+ < div className = "flex flex-col gap-2 md:gap-4 " >
131+ < h1 className = "text-3xl font-bold leading-tight tracking-tighter text-balance md:text-4xl md:leading-none lg:text-5xl " >
132+ Sponsor CodingCat.dev
68133 </ h1 >
134+ < p className = "text-lg text-muted-foreground" >
135+ Reach a large audience of developers, students, and tech
136+ enthusiasts.
137+ </ p >
138+ </ div >
139+
140+ < div className = "grid gap-8 md:grid-cols-2 lg:grid-cols-3" >
141+ { sponsorshipTiers . map ( ( tier ) => (
142+ < Card key = { tier . value } >
143+ < CardHeader >
144+ < CardTitle > { tier . name } </ CardTitle >
145+ </ CardHeader >
146+ < CardContent >
147+ < p className = "text-2xl font-bold" > { tier . price } </ p >
148+ < p className = "mt-2 text-muted-foreground" >
149+ { tier . description }
150+ </ p >
151+ </ CardContent >
152+ </ Card >
153+ ) ) }
69154 </ div >
70- < div className = "flex flex-col w-full gap-2 md:gap-8 max-w-7xl" >
71155
156+ < div className = "my-8" >
157+ < h2 className = "text-2xl font-bold text-center" >
158+ Ready to Sponsor?
159+ </ h2 >
160+ < p className = "text-center text-muted-foreground" >
161+ Fill out the form below to get in touch.
162+ </ p >
163+ < Form { ...form } >
164+ < form
165+ onSubmit = { form . handleSubmit ( onSubmit ) }
166+ className = "space-y-8 max-w-xl mx-auto mt-8"
167+ >
168+ < FormField
169+ control = { form . control }
170+ name = "fullName"
171+ render = { ( { field } ) => (
172+ < FormItem >
173+ < FormLabel > Full Name</ FormLabel >
174+ < FormControl >
175+ < Input placeholder = "Your Name" { ...field } />
176+ </ FormControl >
177+ < FormMessage />
178+ </ FormItem >
179+ ) }
180+ />
181+ < FormField
182+ control = { form . control }
183+ name = "email"
184+ render = { ( { field } ) => (
185+ < FormItem >
186+ < FormLabel > Email Address</ FormLabel >
187+ < FormControl >
188+ < Input placeholder = "your.email@example.com" { ...field } />
189+ </ FormControl >
190+ < FormMessage />
191+ </ FormItem >
192+ ) }
193+ />
194+ < FormField
195+ control = { form . control }
196+ name = "companyName"
197+ render = { ( { field } ) => (
198+ < FormItem >
199+ < FormLabel > Company Name</ FormLabel >
200+ < FormControl >
201+ < Input placeholder = "Your Company" { ...field } />
202+ </ FormControl >
203+ < FormMessage />
204+ </ FormItem >
205+ ) }
206+ />
207+ < FormField
208+ control = { form . control }
209+ name = "sponsorshipTier"
210+ render = { ( { field } ) => (
211+ < FormItem >
212+ < FormLabel > Sponsorship Tier of Interest</ FormLabel >
213+ < Select
214+ onValueChange = { field . onChange }
215+ defaultValue = { field . value }
216+ >
217+ < FormControl >
218+ < SelectTrigger >
219+ < SelectValue placeholder = "Select a tier" />
220+ </ SelectTrigger >
221+ </ FormControl >
222+ < SelectContent >
223+ { sponsorshipTiers . map ( ( tier ) => (
224+ < SelectItem key = { tier . value } value = { tier . value } >
225+ { tier . name }
226+ </ SelectItem >
227+ ) ) }
228+ </ SelectContent >
229+ </ Select >
230+ < FormMessage />
231+ </ FormItem >
232+ ) }
233+ />
234+ < FormField
235+ control = { form . control }
236+ name = "message"
237+ render = { ( { field } ) => (
238+ < FormItem >
239+ < FormLabel > Message</ FormLabel >
240+ < FormControl >
241+ < Textarea
242+ placeholder = "Tell us a little bit about your company and what you'd like to promote."
243+ className = "resize-none"
244+ { ...field }
245+ />
246+ </ FormControl >
247+ < FormMessage />
248+ </ FormItem >
249+ ) }
250+ />
251+ { /* Honeypot field - do not remove */ }
252+ < FormField
253+ control = { form . control }
254+ name = "honeypot"
255+ render = { ( { field } ) => (
256+ < FormItem className = "hidden" >
257+ < FormControl >
258+ < Input { ...field } />
259+ </ FormControl >
260+ </ FormItem >
261+ ) }
262+ />
263+ < FormField
264+ control = { form . control }
265+ name = "cf-turnstile-response"
266+ render = { ( { field, fieldState } ) => (
267+ < FormItem >
268+ < FormControl >
269+ < CloudflareTurnstileWidget { ...field } />
270+ </ FormControl >
271+ < FormMessage />
272+ </ FormItem >
273+ ) }
274+ />
275+ < Button type = "submit" > Submit Request</ Button >
276+ </ form >
277+ </ Form >
72278 </ div >
73- < article >
74- { page . content ?. length && (
75- < PortableText
76- className = "prose-violet lg:prose-xl dark:prose-invert"
77- value = { page . content as PortableTextBlock [ ] }
78- />
79- ) }
80- </ article >
81279 </ div >
82280 </ div >
83281 ) ;
84- }
282+ }
0 commit comments