diff --git a/apps/blog/package.json b/apps/blog/package.json index 7e4aee2d8a..dad6bb49e2 100644 --- a/apps/blog/package.json +++ b/apps/blog/package.json @@ -18,6 +18,7 @@ "@fumadocs/cli": "catalog:", "@prisma/eclipse": "workspace:^", "@prisma-docs/ui": "workspace:*", + "cors": "^2.8.6", "fumadocs-core": "catalog:", "fumadocs-mdx": "catalog:", "fumadocs-openapi": "catalog:", diff --git a/apps/blog/src/app/(blog)/[slug]/page.tsx b/apps/blog/src/app/(blog)/[slug]/page.tsx index 497e47906f..553d88646b 100644 --- a/apps/blog/src/app/(blog)/[slug]/page.tsx +++ b/apps/blog/src/app/(blog)/[slug]/page.tsx @@ -1,11 +1,23 @@ -import { notFound } from 'next/navigation'; -import Link from 'next/link'; -import { InlineTOC } from 'fumadocs-ui/components/inline-toc'; -import { getMDXComponents } from '@/mdx-components'; -import { createRelativeLink } from 'fumadocs-ui/mdx'; -import { blog } from '@/lib/source'; -import Image from 'next/image'; -import { withBlogBasePathForImageSrc } from '@/lib/url'; +import { formatTag, formatDate } from "@/lib/format"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { getMDXComponents } from "@/mdx-components"; +import { createRelativeLink } from "fumadocs-ui/mdx"; +import { blog } from "@/lib/source"; +import { + Action, + Avatar, + Badge, + Button, + cn, + InlineTOC, + Input, + Label, + Separator, +} from "@prisma-docs/eclipse"; +import { FooterNewsletterForm } from "@prisma-docs/ui/components/newsletter"; +import { BlogShare } from "@/components/BlogShare"; + export default async function Page(props: { params: Promise<{ slug: string }>; }) { @@ -14,106 +26,89 @@ export default async function Page(props: { if (!page) notFound(); const MDX = page.data.body; - const formatDate = (value: unknown) => { - const date = - value instanceof Date ? value : new Date((value as string) ?? ''); - if (Number.isNaN(date.getTime())) return ''; - return date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - }); - }; - const getHeroImageSrc = () => { - const data = page.data as any; - const rel = - (data.heroImagePath as string | undefined) ?? - (data.metaImagePath as string | undefined); - if (rel) { - if (rel.startsWith('/')) return rel; - const base = page.url.startsWith('/') ? page.url : `/${page.url}`; - const baseClean = base.endsWith('/') ? base.slice(0, -1) : base; - const relClean = rel.replace(/^\.\//, '').replace(/^\/+/, ''); - return `${baseClean}/${relClean}`; - } - const absolute = - (data.heroImageUrl as string | undefined) ?? - (data.metaImageUrl as string | undefined); - return absolute ?? null; - }; - const heroSrc = getHeroImageSrc(); return ( - <> - {/* Hero image */} - {heroSrc ? ( -
-
- {(page.data +
+ {/* Title + meta */} +
+ + ← Back to Blog + +

+ {page.data.title} +

+
+ {page.data.authors?.length > 1 ? ( + page.data.authors.join(", ") + ) : page.data.authors?.length === 1 ? ( + + {page.data?.authorSrc && ( + + )} + {page.data.authors[0]} + + ) : null} + {page.data.date ? ( + <> + + + {formatDate(new Date(page.data.date).toISOString())} + + + ) : null} +
+ {page.data.tags && page.data.tags.length > 0 && ( +
+ {page.data?.tags?.map((tag) => ( + + ))} +
+ )} +
+ + {/* Body */} +
+
+
-
- ) : null} + + - {/* Title + meta */} -
- - ← Back to Blog - -

- {page.data.title} -

- {page.data.description ? ( -

{page.data.description}

- ) : null} -

- {page.data.authors?.length ? page.data.authors.join(', ') : null} - {page.data.date ? ( - <> - {' • '} - {formatDate(page.data.date)} - - ) : null} -

-
+ {/* Share Container */} + - {/* Body */} -
-
- - + {/* Newsletter CTA */} +
+
-
- - {/* Newsletter CTA */} -
-
-

- Don’t miss the next post! -

-

- Sign up for the Prisma Newsletter to stay up to date with the latest - releases and posts. -

- - Sign up - +
+
+
+ + On this page + +
- +
); } diff --git a/apps/blog/src/app/(blog)/page.tsx b/apps/blog/src/app/(blog)/page.tsx index e5a0f95d0b..4f2fe37465 100644 --- a/apps/blog/src/app/(blog)/page.tsx +++ b/apps/blog/src/app/(blog)/page.tsx @@ -45,7 +45,7 @@ export default function BlogHome() { description: (data.description as string) || (data.metaDescription as string) || "", author: getPrimaryAuthor(post), - authorSrc: null, + authorSrc: (data.authorSrc as string | undefined) ?? null, imageSrc: getCardImageSrc(post), imageAlt: (data.heroImageAlt as string) ?? (data.title as string), seriesTitle: data.series?.title ?? null, diff --git a/apps/blog/src/app/api/newsletter/route.ts b/apps/blog/src/app/api/newsletter/route.ts new file mode 100644 index 0000000000..e4ef7c1352 --- /dev/null +++ b/apps/blog/src/app/api/newsletter/route.ts @@ -0,0 +1,175 @@ +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +// CORS headers configuration +const corsHeaders = { + "Access-Control-Allow-Origin": + "https://prisma.io, https://www.prisma.io, https://prisma.io/docs", + "Access-Control-Allow-Methods": "POST, GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", +}; + +export async function OPTIONS() { + return NextResponse.json({}, { headers: corsHeaders, status: 200 }); +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { email } = body; + + if (!email || typeof email !== "string") { + return NextResponse.json( + { error: "Email is required" }, + { status: 400, headers: corsHeaders }, + ); + } + + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return NextResponse.json( + { error: "Invalid email address" }, + { status: 400, headers: corsHeaders }, + ); + } + + // Check for required environment variable + const brevoApiKey = process.env.BREVO_API_KEY; + if (!brevoApiKey) { + console.error("Missing Brevo API key"); + return NextResponse.json( + { error: "Newsletter service is not configured" }, + { status: 500, headers: corsHeaders }, + ); + } + + const options = { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + "api-key": brevoApiKey, + }, + body: JSON.stringify({ + email: email, + attributes: { + EMAIL: email, + SOURCE: "website", + }, + includeListIds: [15], + templateId: 36, + redirectionUrl: "https://prisma.io", + }), + }; + + const response = await fetch( + "https://api.brevo.com/v3/contacts/doubleOptinConfirmation", + options, + ); + + // Get response text first to check if it's empty + const responseText = await response.text(); + + let data: any = null; + + // Only try to parse JSON if there's actual content + if (responseText && responseText.length > 0) { + try { + data = JSON.parse(responseText); + } catch (parseError) { + console.error("Failed to parse Brevo response:", { + text: responseText, + status: response.status, + parseError, + }); + + // If response was successful but JSON parse failed, treat as success + if (response.ok) { + return NextResponse.json( + { message: "Please check your email to confirm subscription" }, + { status: 200, headers: corsHeaders }, + ); + } + + return NextResponse.json( + { error: "Invalid response from newsletter service" }, + { status: 500, headers: corsHeaders }, + ); + } + } + + // Handle error responses + if (!response.ok) { + console.error("Brevo error:", { + status: response.status, + statusText: response.statusText, + data, + email, + }); + + // Handle specific Brevo errors + if ( + data?.code === "duplicate_parameter" || + data?.message?.includes("already exists") + ) { + return NextResponse.json( + { message: "Already subscribed", alreadySubscribed: true }, + { status: 200, headers: corsHeaders }, + ); + } + + return NextResponse.json( + { + error: + data?.message || "Failed to subscribe. Please try again later.", + debug: + process.env.NODE_ENV === "development" + ? { + status: response.status, + statusText: response.statusText, + brevoError: data, + responseText, + } + : undefined, + }, + { status: 500, headers: corsHeaders }, + ); + } + + // Success - Brevo may return empty body on success + return NextResponse.json( + { message: "Please check your email to confirm subscription" }, + { status: 200, headers: corsHeaders }, + ); + } catch (error) { + console.error("Newsletter subscription error:", error); + const errorMessage = + error instanceof Error ? error.message : "An unexpected error occurred"; + + return NextResponse.json( + { + error: errorMessage, + debug: + process.env.NODE_ENV === "development" + ? { + errorType: + error instanceof Error + ? error.constructor.name + : typeof error, + stack: error instanceof Error ? error.stack : undefined, + } + : undefined, + }, + { status: 500, headers: corsHeaders }, + ); + } +} + +export async function GET() { + return NextResponse.json( + { error: "Method Not Allowed" }, + { status: 405, headers: { ...corsHeaders, Allow: "POST" } }, + ); +} diff --git a/apps/blog/src/app/global.css b/apps/blog/src/app/global.css index ac161e9507..3650d8a406 100644 --- a/apps/blog/src/app/global.css +++ b/apps/blog/src/app/global.css @@ -8,6 +8,10 @@ --color-fd-primary: var(--color-stroke-ppg); } +.bg-blog { + background-color: var(--color-background-default); +} + .bg-blog::after { content: ""; position: absolute; diff --git a/apps/blog/src/components/BlogGrid.tsx b/apps/blog/src/components/BlogGrid.tsx index 08f64c6e3e..27977d2638 100644 --- a/apps/blog/src/components/BlogGrid.tsx +++ b/apps/blog/src/components/BlogGrid.tsx @@ -290,6 +290,12 @@ export function BlogGrid({ window.scrollTo({ top: 0, behavior: "smooth" }); }, [currentCat, currentPage, pathname, router, totalPages]); + const formatTag = (tag: string) => { + return tag === "orm" + ? "ORM" + : tag.replace(/-/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()); + }; + return ( <> {/* Category pills */} diff --git a/apps/blog/src/components/BlogShare.tsx b/apps/blog/src/components/BlogShare.tsx new file mode 100644 index 0000000000..264b15ea38 --- /dev/null +++ b/apps/blog/src/components/BlogShare.tsx @@ -0,0 +1,84 @@ +"use client"; +import { + Action, + cn, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@prisma-docs/eclipse"; +import { shareSocials } from "@prisma-docs/ui/data/footer"; +import { usePathname } from "next/navigation"; +import { useState } from "react"; + +const defaultCopyText = "Copy article link"; + +export const BlogShare = ({ desc }: { desc: string }) => { + const [tooltip, setTooltip] = useState(defaultCopyText); + const pathname = usePathname(); + + return ( +
+
+ Share this article +
+
+ {shareSocials.map((socialLink: any) => + socialLink.url ? ( + + + + div]:bg-background-ppg-strong", + )} + > + + + + + + {socialLink.label} + + + ) : socialLink.copy ? ( + + + + { + setTooltip("Link copied!"); + setTimeout(() => { + setTooltip(defaultCopyText); + }, 500); + navigator.clipboard.writeText( + `https://prisma.io/blog${pathname}`, + ); + }} + className="text-[1.375rem] transition-colors hover:bg-background-ppg-strong cursor-pointer" + > + + + + {tooltip} + + + ) : null, + )} +
+
+ ); +}; diff --git a/apps/blog/src/components/Quote.tsx b/apps/blog/src/components/Quote.tsx index 6d94228461..d7f00e5ba6 100644 --- a/apps/blog/src/components/Quote.tsx +++ b/apps/blog/src/components/Quote.tsx @@ -21,11 +21,13 @@ export function Quotes({ }: QuoteProps) { return (
-
{children}
-
+
+ {children} +
+
{speakerImgLink && ( {speakerName; +} +``` + +## API Endpoint + +**POST** `/api/newsletter` + +### Request Body + +```json +{ + "email": "user@example.com" +} +``` + +### Response Codes + +- **200**: Successfully added to list (confirmation email sent) or already subscribed +- **400**: Invalid email or missing email +- **500**: Server error or missing configuration + +### Response Examples + +**Success (200)** +```json +{ + "message": "Please check your email to confirm subscription" +} +``` + +**Already Subscribed (200)** +```json +{ + "message": "Already subscribed", + "alreadySubscribed": true +} +``` + +**Error (400)** +```json +{ + "error": "Invalid email address" +} +``` + +**Error (500)** +```json +{ + "error": "Newsletter service is not configured" +} +``` + +## Double Opt-In + +This implementation uses Brevo's double opt-in feature: + +1. User submits their email +2. Brevo sends a confirmation email using the configured template +3. User clicks the confirmation link +4. Subscription is confirmed and user is redirected to https://prisma.io + +This ensures compliance with GDPR and other privacy regulations. + +## Troubleshooting + +### "Newsletter service is not configured" + +Check that the `BREVO_API_KEY` environment variable is set correctly. + +### "Failed to subscribe" + +Check the server logs for detailed error messages from Brevo. Common issues: +- Invalid API key +- Incorrect list ID (update line 60 in route.ts if different from `15`) +- Incorrect template ID (update line 61 in route.ts if different from `36`) +- Brevo API rate limits + +### Development Mode Debug Info + +In development mode (`NODE_ENV=development`), the API will return additional debug information in the error response: + +```json +{ + "error": "Failed to subscribe. Please try again later.", + "debug": { + "status": 400, + "brevoError": { + "code": "invalid_parameter", + "message": "..." + } + } +} +``` + +Check the browser console for "API Error Debug:" logs. + +### Testing Locally + +Create a `.env.local` file in the app root: + +```bash +BREVO_API_KEY=your_api_key_here +``` + +Restart your development server after adding environment variables. + +## Customization + +To customize the list ID or template ID, edit the API route: + +```typescript +// In route.ts, around line 60-61 +includeListIds: [15], // Change to your list ID +templateId: 36, // Change to your template ID +``` + +## CORS Configuration + +The API is configured to allow requests from: +- https://prisma.io +- https://www.prisma.io +- https://prisma.io/docs + +To add more origins, update the `corsHeaders` in `route.ts`. \ No newline at end of file diff --git a/apps/docs/src/app/api/newsletter/route.ts b/apps/docs/src/app/api/newsletter/route.ts new file mode 100644 index 0000000000..e4ef7c1352 --- /dev/null +++ b/apps/docs/src/app/api/newsletter/route.ts @@ -0,0 +1,175 @@ +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +// CORS headers configuration +const corsHeaders = { + "Access-Control-Allow-Origin": + "https://prisma.io, https://www.prisma.io, https://prisma.io/docs", + "Access-Control-Allow-Methods": "POST, GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", +}; + +export async function OPTIONS() { + return NextResponse.json({}, { headers: corsHeaders, status: 200 }); +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { email } = body; + + if (!email || typeof email !== "string") { + return NextResponse.json( + { error: "Email is required" }, + { status: 400, headers: corsHeaders }, + ); + } + + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return NextResponse.json( + { error: "Invalid email address" }, + { status: 400, headers: corsHeaders }, + ); + } + + // Check for required environment variable + const brevoApiKey = process.env.BREVO_API_KEY; + if (!brevoApiKey) { + console.error("Missing Brevo API key"); + return NextResponse.json( + { error: "Newsletter service is not configured" }, + { status: 500, headers: corsHeaders }, + ); + } + + const options = { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + "api-key": brevoApiKey, + }, + body: JSON.stringify({ + email: email, + attributes: { + EMAIL: email, + SOURCE: "website", + }, + includeListIds: [15], + templateId: 36, + redirectionUrl: "https://prisma.io", + }), + }; + + const response = await fetch( + "https://api.brevo.com/v3/contacts/doubleOptinConfirmation", + options, + ); + + // Get response text first to check if it's empty + const responseText = await response.text(); + + let data: any = null; + + // Only try to parse JSON if there's actual content + if (responseText && responseText.length > 0) { + try { + data = JSON.parse(responseText); + } catch (parseError) { + console.error("Failed to parse Brevo response:", { + text: responseText, + status: response.status, + parseError, + }); + + // If response was successful but JSON parse failed, treat as success + if (response.ok) { + return NextResponse.json( + { message: "Please check your email to confirm subscription" }, + { status: 200, headers: corsHeaders }, + ); + } + + return NextResponse.json( + { error: "Invalid response from newsletter service" }, + { status: 500, headers: corsHeaders }, + ); + } + } + + // Handle error responses + if (!response.ok) { + console.error("Brevo error:", { + status: response.status, + statusText: response.statusText, + data, + email, + }); + + // Handle specific Brevo errors + if ( + data?.code === "duplicate_parameter" || + data?.message?.includes("already exists") + ) { + return NextResponse.json( + { message: "Already subscribed", alreadySubscribed: true }, + { status: 200, headers: corsHeaders }, + ); + } + + return NextResponse.json( + { + error: + data?.message || "Failed to subscribe. Please try again later.", + debug: + process.env.NODE_ENV === "development" + ? { + status: response.status, + statusText: response.statusText, + brevoError: data, + responseText, + } + : undefined, + }, + { status: 500, headers: corsHeaders }, + ); + } + + // Success - Brevo may return empty body on success + return NextResponse.json( + { message: "Please check your email to confirm subscription" }, + { status: 200, headers: corsHeaders }, + ); + } catch (error) { + console.error("Newsletter subscription error:", error); + const errorMessage = + error instanceof Error ? error.message : "An unexpected error occurred"; + + return NextResponse.json( + { + error: errorMessage, + debug: + process.env.NODE_ENV === "development" + ? { + errorType: + error instanceof Error + ? error.constructor.name + : typeof error, + stack: error instanceof Error ? error.stack : undefined, + } + : undefined, + }, + { status: 500, headers: corsHeaders }, + ); + } +} + +export async function GET() { + return NextResponse.json( + { error: "Method Not Allowed" }, + { status: 405, headers: { ...corsHeaders, Allow: "POST" } }, + ); +} diff --git a/apps/eclipse/src/app/api/search/route.ts b/apps/eclipse/src/app/api/search/route.ts deleted file mode 100644 index 85448f9d32..0000000000 --- a/apps/eclipse/src/app/api/search/route.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { source } from "@/lib/source"; -import { createFromSource } from "fumadocs-core/search/server"; - -export const revalidate = false; - -export const { staticGET: GET } = createFromSource(source, { - // https://docs.orama.com/docs/orama-js/supported-languages - language: "english", -}); diff --git a/packages/eclipse/src/styles/fonts.css b/packages/eclipse/src/styles/fonts.css index 5e73687106..f7b2a50dca 100644 --- a/packages/eclipse/src/styles/fonts.css +++ b/packages/eclipse/src/styles/fonts.css @@ -26,5 +26,13 @@ } html { - font-family: "Inter"; + font-family: Inter, system-ui, sans-serif; +} + +@font-face { + font-family: "Mona Sans"; + src: url("../static/fonts/MonaSans-ExtraBold.ttf") format("truetype"); + font-weight: 900; + font-style: normal; + font-display: swap; } diff --git a/packages/ui/package.json b/packages/ui/package.json index b154220a0c..aa9e9c8c54 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -8,6 +8,7 @@ "./lib/*": "./src/lib/*.ts", "./hooks/*": "./src/hooks/*.ts", "./components/*": "./src/components/*.tsx", + "./data/*": "./src/data/*.ts", "./styles": "./src/styles/globals.css" }, "scripts": { diff --git a/packages/ui/src/components/newsletter.tsx b/packages/ui/src/components/newsletter.tsx new file mode 100644 index 0000000000..ea6a367d1f --- /dev/null +++ b/packages/ui/src/components/newsletter.tsx @@ -0,0 +1,105 @@ +"use client"; +import React from "react"; + +import { Button, cn, Input } from "@prisma-docs/eclipse"; +import { useNewsletter } from "../hooks/use-newsletter"; + +const icon = (name: string) => ( + +); + +type ColorType = "indigo" | "teal" | "white" | undefined; + +type FooterNewsletterFormProps = { + theme?: any; + color?: ColorType; + blog?: boolean; + apiUrl?: string; +}; + +export const FooterNewsletterForm = ({ + blog = false, + apiUrl, +}: FooterNewsletterFormProps) => { + const { + email, + setEmail, + isSubmitting, + isSubmitted, + isAlreadySubscribed, + error, + subscribe, + } = useNewsletter({ apiUrl }); + + const buttonText = blog ? "Sign up" : "Subscribe"; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + await subscribe(); + }; + + const getButtonText = () => { + if (isSubmitting) return "Submitting..."; + if (isSubmitted) return "Thank you!"; + if (isAlreadySubscribed) return "Already subscribed!"; + return buttonText; + }; + + return ( +
+
+
+ Subscribe to our newsletter +
+
+
+ + +
+ {error && ( +

+ {error} +

+ )} + {isSubmitted && ( +

+ Please check your email to confirm your subscription! +

+ )} + {isAlreadySubscribed && ( +

+ You're already subscribed to our newsletter! +

+ )} +
+
+
+ ); +}; diff --git a/packages/ui/src/data/footer.ts b/packages/ui/src/data/footer.ts index dc2988ca7f..96c8a94043 100644 --- a/packages/ui/src/data/footer.ts +++ b/packages/ui/src/data/footer.ts @@ -189,9 +189,53 @@ const socialIcons = [ }, ]; +const shareSocials = [ + { + label: "LinkedIn", + icon: "fa-brands fa-square-linkedin", + url: ({ + current_page, + text_data, + }: { + current_page: string; + text_data: string; + }) => `https://www.linkedin.com/sharing/share-offsite/?url=${current_page}`, + }, + { + label: "X", + icon: "fa-brands fa-x-twitter", + url: ({ + current_page, + text_data, + hashtags, + }: { + current_page: string; + text_data: string; + hashtags: Array; + }) => + `http://x.com/share?text=${text_data}&url=${current_page}${ + hashtags ? `&hashtags=${hashtags.join()}` : `` + }`, + }, + { + label: "Bluesky", + icon: "fa-brands fa-bluesky", + url: ({ + current_page, + text_data, + }: { + current_page: string; + text_data: string; + }) => `https://bsky.app/intent/compose?text=${text_data}${current_page}`, + }, + { label: "Copy link", icon: "fa-solid fa-link", copy: true }, +]; + const footerData = { footerItems, socialIcons, + shareSocials, }; +export { footerItems, socialIcons, shareSocials }; export default footerData; diff --git a/packages/ui/src/hooks/use-newsletter.ts b/packages/ui/src/hooks/use-newsletter.ts new file mode 100644 index 0000000000..b38c607d35 --- /dev/null +++ b/packages/ui/src/hooks/use-newsletter.ts @@ -0,0 +1,129 @@ +import { useState } from "react"; + +interface UseNewsletterOptions { + apiUrl?: string; +} + +interface NewsletterState { + email: string; + isSubmitting: boolean; + isSubmitted: boolean; + isAlreadySubscribed: boolean; + error: string | null; +} + +interface UseNewsletterReturn extends NewsletterState { + setEmail: (email: string) => void; + subscribe: () => Promise; + reset: () => void; +} + +export const useNewsletter = ( + options: UseNewsletterOptions = {}, +): UseNewsletterReturn => { + const { apiUrl = "/api/newsletter" } = options; + + const [email, setEmail] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSubmitted, setIsSubmitted] = useState(false); + const [isAlreadySubscribed, setIsAlreadySubscribed] = + useState(false); + const [error, setError] = useState(null); + + const subscribe = async () => { + if (!email) { + setError("Email is required"); + return; + } + + // Basic email validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + setError("Please enter a valid email address"); + return; + } + + setIsSubmitting(true); + setError(null); + setIsSubmitted(false); + setIsAlreadySubscribed(false); + + try { + const response = await fetch(apiUrl, { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + }, + body: JSON.stringify({ email }), + }); + + // Check if response has content before parsing JSON + const contentType = response.headers.get("content-type"); + const hasJson = contentType && contentType.includes("application/json"); + + let data: any = {}; + + if (hasJson) { + const text = await response.text(); + if (text && text.length > 0) { + try { + data = JSON.parse(text); + } catch (parseError) { + console.error("Failed to parse JSON:", text); + throw new Error("Invalid response from server. Please try again."); + } + } + } + + if (!response.ok) { + // Show debug info in development + if (data.debug) { + console.error("API Error Debug:", data.debug); + } + throw new Error(data.error || "Failed to subscribe. Please try again."); + } + + // Check if already subscribed (from response data) + if (data.alreadySubscribed) { + setIsAlreadySubscribed(true); + setEmail(""); + } else if (response.status === 200 || response.status === 201) { + // Successful subscription (200 for Brevo, 201 for Mailchimp) + setIsSubmitted(true); + setEmail(""); + } else { + // Other success status + setIsSubmitted(true); + setEmail(""); + } + } catch (err) { + if (err instanceof Error) { + setError(err.message); + } else { + setError("An unexpected error occurred. Please try again."); + } + } finally { + setIsSubmitting(false); + } + }; + + const reset = () => { + setEmail(""); + setIsSubmitting(false); + setIsSubmitted(false); + setIsAlreadySubscribed(false); + setError(null); + }; + + return { + email, + setEmail, + isSubmitting, + isSubmitted, + isAlreadySubscribed, + error, + subscribe, + reset, + }; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d558e94c4..274a90629a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,6 +252,9 @@ importers: '@prisma/eclipse': specifier: workspace:^ version: link:../../packages/eclipse + cors: + specifier: ^2.8.6 + version: 2.8.6 fumadocs-core: specifier: 'catalog:' version: 16.6.13(@mdx-js/mdx@3.1.1)(@mixedbread/sdk@0.46.0)(@types/estree-jsx@1.0.5)(@types/hast@3.0.4)(@types/mdast@4.0.4)(@types/react@19.2.14)(lucide-react@0.575.0(react@19.2.4))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) @@ -3970,6 +3973,10 @@ packages: core-js@3.48.0: resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -5070,6 +5077,10 @@ packages: resolution: {integrity: sha512-tt6PvKu4WyzPwWUzy/hvPFqn+uwXO0K1ZHka8az3NnrhWJDmSqI8ncWq0fkL0k/lmmi5tAC11FXwXuh0rFbt1A==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -5821,6 +5832,10 @@ packages: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vaul@1.1.2: resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} peerDependencies: @@ -8808,6 +8823,11 @@ snapshots: core-js@3.48.0: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -10208,6 +10228,8 @@ snapshots: npm-to-yarn@3.0.1: {} + object-assign@4.1.1: {} + obug@2.1.1: {} oniguruma-parser@0.12.1: {} @@ -11158,6 +11180,8 @@ snapshots: uuid@9.0.1: {} + vary@1.1.2: {} + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)