Skip to content

Commit 78eec15

Browse files
committed
add request page
1 parent e7bb712 commit 78eec15

11 files changed

Lines changed: 558 additions & 65 deletions

File tree

GEMINI.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,4 @@ The project follows a standard Next.js app directory structure. It uses Sanity f
3131
* **Styling:** The project uses Tailwind CSS for styling.
3232
* **UI Components:** The project uses Radix UI for accessible UI components.
3333
* **Code Formatting:** The project uses Biome for code formatting.
34+
* **Language:** All components should be React and TypeScript should be used everywhere.
Lines changed: 262 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,282 @@
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

Comments
 (0)