diff --git a/apps/www/_blog/2025-04-04-data-api-nearest-read-replica.mdx b/apps/www/_blog/2025-04-04-data-api-nearest-read-replica.mdx index 01dd02ec79..30ad916405 100644 --- a/apps/www/_blog/2025-04-04-data-api-nearest-read-replica.mdx +++ b/apps/www/_blog/2025-04-04-data-api-nearest-read-replica.mdx @@ -2,8 +2,8 @@ title: 'Data API Routes to Nearest Read Replica' description: Route your Data API (PostgREST) requests to the nearest Read Replica author: jose -image: lw14-data-api-nearest-rr/geo-aware-routing-og.png -thumb: lw14-data-api-nearest-rr/geo-aware-routing-thumb.png +image: lw14-data-api-nearest-rr/og.png +thumb: lw14-data-api-nearest-rr/thumb.png categories: - launch-week - product diff --git a/apps/www/app/api-v2/submit-form-talk-to-partnership/route.tsx b/apps/www/app/api-v2/submit-form-talk-to-partnership/route.tsx new file mode 100644 index 0000000000..b41b906430 --- /dev/null +++ b/apps/www/app/api-v2/submit-form-talk-to-partnership/route.tsx @@ -0,0 +1,110 @@ +const corsHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', +} + +const personalEmailDomains = [ + '@gmail.com', + '@yahoo.com', + '@hotmail.', + '@outlook.com', + '@aol.com', + '@icloud.com', + '@live.com', + '@protonmail.com', + '@mail.com', + '@example.com', +] + +const isValidEmail = (email: string): boolean => { + const emailPattern = /^[\w-\.+]+@([\w-]+\.)+[\w-]{2,8}$/ + return emailPattern.test(email) +} + +const isCompanyEmail = (email: string): boolean => { + for (const domain of personalEmailDomains) { + if (email.includes(domain)) { + return false + } + } + + return true +} + +export async function POST(req: Request) { + const HUBSPOT_PORTAL_ID = process.env.HUBSPOT_PORTAL_ID + const HUBSPOT_FORM_GUID = process.env.HUBSPOT_PARTNERSHIP_FORM_GUID + + const body = await req.json() + const { firstName, secondName, companyEmail } = body + + if (!firstName || !secondName || !companyEmail) { + return new Response(JSON.stringify({ message: 'All fields are required' }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 422, + }) + } + + // Validate email + if (companyEmail && !isValidEmail(companyEmail)) { + return new Response(JSON.stringify({ message: 'Invalid email address' }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 422, + }) + } + + // Validate company email + if (companyEmail && !isCompanyEmail(companyEmail)) { + return new Response(JSON.stringify({ message: 'Please use a company email address' }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 422, + }) + } + + try { + const response = await fetch( + `https://api.hsforms.com/submissions/v3/integration/submit/${HUBSPOT_PORTAL_ID}/${HUBSPOT_FORM_GUID}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fields: [ + { objectTypeId: '0-1', name: 'firstname', value: firstName }, + { objectTypeId: '0-1', name: 'lastname', value: secondName }, + { objectTypeId: '0-1', name: 'email', value: companyEmail }, + ], + context: { + pageUri: 'https://supabase.com/solutions/ai-builders', + pageName: 'Solutions / AI Builders', + }, + legalConsentOptions: { + consent: { + consentToProcess: true, + text: 'By submitting this form, I confirm that I have read and understood the Privacy Policy.', + }, + }, + }), + } + ) + + if (!response.ok) { + const errorData = await response.json() + return new Response(JSON.stringify({ message: errorData.message }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: response.status, + }) + } + + return new Response(JSON.stringify({ message: 'Submission successful' }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 200, + }) + } catch (error: any) { + return new Response(JSON.stringify({ error: error.message }), { + headers: { ...corsHeaders, 'Content-Type': 'application/json' }, + status: 500, + }) + } +} diff --git a/apps/www/components/Enterprise/Support.tsx b/apps/www/components/Enterprise/Support.tsx index d10c11ff77..9b6adae163 100644 --- a/apps/www/components/Enterprise/Support.tsx +++ b/apps/www/components/Enterprise/Support.tsx @@ -7,13 +7,13 @@ import type { LucideIcon } from 'lucide-react' interface Props { id: string - label: string | JSX.Element + label?: string | JSX.Element heading: string | JSX.Element features: Feature[] } type Feature = { - icon: LucideIcon + icon: LucideIcon | any heading: string subheading: string } diff --git a/apps/www/components/Enterprise/UseCases.tsx b/apps/www/components/Enterprise/UseCases.tsx index ec6488a85a..d37b7819b3 100644 --- a/apps/www/components/Enterprise/UseCases.tsx +++ b/apps/www/components/Enterprise/UseCases.tsx @@ -13,22 +13,22 @@ import Image from 'next/image' interface Props { id: string - label: string | JSX.Element - heading: string | JSX.Element - stories: Story[] - highlights: Highlight[] + label?: string | JSX.Element + heading?: string | JSX.Element + stories?: Story[] + highlights?: Highlight[] } export type Story = { - icon: string + icon?: string url: string target?: '_blank' | string - heading: string - subheading: string | JSX.Element + heading?: string + subheading?: string | JSX.Element } type Highlight = { - icon: LucideIcon + icon?: LucideIcon heading: string subheading: string url: string @@ -45,7 +45,7 @@ const UseCases: FC = (props) => (
    - {props.stories.map((story) => ( + {props.stories?.map((story) => (
  • @@ -74,7 +74,7 @@ const UseCases: FC = (props) => ( }, }} > - {props.stories.map((story: Story, i: number) => ( + {props.stories?.map((story: Story, i: number) => ( @@ -85,7 +85,7 @@ const UseCases: FC = (props) => (
@@ -105,13 +105,15 @@ const StoryCard: FC = ({ story }) => ( innerClassName="flex flex-col justify-between text-foreground-lighter bg-surface-75 p-2" >
- {story.heading} + {story.icon && ( + {story.heading + )}

{story.heading}

@@ -126,11 +128,11 @@ interface HighlightCardProps { } const HighlightCard: FC = ({ highlight }) => { - const Icon: LucideIcon = highlight.icon + const Icon: LucideIcon | undefined = highlight.icon return (
  • - + {Icon && }

    {highlight.heading}

    {highlight.subheading}

    diff --git a/apps/www/components/Forms/TalkToPartnershipTeamForm.tsx b/apps/www/components/Forms/TalkToPartnershipTeamForm.tsx new file mode 100644 index 0000000000..fc118e3529 --- /dev/null +++ b/apps/www/components/Forms/TalkToPartnershipTeamForm.tsx @@ -0,0 +1,285 @@ +import { FC, useEffect, useState } from 'react' +import Link from 'next/link' +import { CircleAlert } from 'lucide-react' +import { Button, cn, Input_Shadcn_, Label_Shadcn_, Separator, TextArea_Shadcn_ } from 'ui' +import { Alert } from 'ui/src/components/shadcn/ui/alert' +import { useSendTelemetryEvent } from '~/lib/telemetry' + +interface FormData { + firstName: string + secondName: string + companyEmail: string +} + +interface FormItem { + type: 'text' | 'textarea' + label: string + placeholder: string + required: boolean + className?: string + component: typeof TextArea_Shadcn_ | typeof Input_Shadcn_ +} + +type FormConfig = { + [K in keyof FormData]: FormItem +} + +interface Props { + className?: string +} + +const formConfig: FormConfig = { + firstName: { + type: 'text', + label: 'First Name', + placeholder: 'First Name', + required: true, + className: 'col-span-full', + component: Input_Shadcn_, + }, + secondName: { + type: 'text', + label: 'Last Name', + placeholder: 'Last Name', + required: true, + className: 'col-span-full', + component: Input_Shadcn_, + }, + companyEmail: { + type: 'text', + label: 'Company Email', + placeholder: 'Company Email', + required: true, + className: '', + component: Input_Shadcn_, + }, +} + +const isValidEmail = (email: string): boolean => { + const emailPattern = /^[\w-\.+]+@([\w-]+\.)+[\w-]{2,8}$/ + return emailPattern.test(email) +} + +const personalEmailDomains = [ + '@gmail.com', + '@yahoo.com', + '@hotmail.', + '@outlook.com', + '@aol.com', + '@icloud.com', + '@live.com', + '@protonmail.com', + '@mail.com', + '@example.com', +] + +const isCompanyEmail = (email: string): boolean => { + for (const domain of personalEmailDomains) { + if (email.includes(domain)) { + return false + } + } + + return true +} + +const defaultFormValue: FormData = { + firstName: '', + secondName: '', + companyEmail: '', +} + +const TalkToPartnershipTeamForm: FC = ({ className }) => { + const [formData, setFormData] = useState(defaultFormValue) + const [honeypot, setHoneypot] = useState('') // field to prevent spam + const [errors, setErrors] = useState<{ [key: string]: string }>({}) + const [isSubmitting, setIsSubmitting] = useState(false) + const [success, setSuccess] = useState(null) + const [startTime, setStartTime] = useState(0) + const sendTelemetryEvent = useSendTelemetryEvent() + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target + setErrors({}) + setFormData((prev) => ({ ...prev, [name]: value })) + } + + const handleReset = (e: React.MouseEvent) => { + e.preventDefault() + setFormData(defaultFormValue) + setSuccess(null) + setErrors({}) + } + + const validate = (): boolean => { + const newErrors: { [key in keyof FormData]?: string } = {} + + // Check required fields + for (const key in formConfig) { + if (formConfig[key as keyof FormData].required && !formData[key as keyof FormData]) { + newErrors[key as keyof FormData] = `This field is required` + } + } + + // Validate email + if (formData.companyEmail && !isValidEmail(formData.companyEmail)) { + newErrors.companyEmail = 'Invalid email address' + } + + // Validate company email + if (formData.companyEmail && !isCompanyEmail(formData.companyEmail)) { + newErrors.companyEmail = 'Please use a company email address' + } + + setErrors(newErrors) + + // Return validation status, also check if honeypot is filled (indicating a bot) + return Object.keys(newErrors).length === 0 && honeypot === '' + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + const currentTime = Date.now() + const timeElapsed = (currentTime - startTime) / 1000 + + // Spam prevention: Reject form if submitted too quickly (less than 3 seconds) + if (timeElapsed < 3) { + setErrors({ general: 'Submission too fast. Please fill the form correctly.' }) + return + } + + if (!validate()) { + return + } + + setIsSubmitting(true) + setSuccess(null) + + try { + const response = await fetch('/api-v2/submit-form-talk-to-partnership', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(formData), + }) + + if (response.ok) { + setSuccess('Thank you for your submission!') + setFormData({ firstName: '', secondName: '', companyEmail: '' }) + } else { + const errorData = await response.json() + setErrors({ general: `Submission failed: ${errorData.message}` }) + } + } catch (error) { + setErrors({ general: 'An unexpected error occurred. Please try again.' }) + } finally { + setIsSubmitting(false) + } + } + + useEffect(() => { + setStartTime(Date.now()) + }, []) + + return ( +
    +
    + {success ? ( +
    +

    {success}

    + +
    + ) : ( +
    + {Object.entries(formConfig).map(([key, { component: Component, ...fieldValue }]) => { + const fieldKey = key as keyof FormData + + return ( +
    + + {fieldValue.label} +
    + {errors[fieldKey]} +
    +
    + +
    + ) + })} + + {/* Spam prevention */} + ) => setHoneypot(e.target.value)} + style={{ display: 'none' }} + aria-hidden="true" + /> + + + +

    + By submitting this form, I confirm that I have read and understood the{' '} + + Privacy Policy + + . +

    + {errors.general && ( + + {errors.general} + + )} + + )} +
    +
    + ) +} + +export default TalkToPartnershipTeamForm diff --git a/apps/www/components/Nav/DevelopersDropdown.tsx b/apps/www/components/Nav/DevelopersDropdown.tsx index b01d36e47e..f11d1c5c26 100644 --- a/apps/www/components/Nav/DevelopersDropdown.tsx +++ b/apps/www/components/Nav/DevelopersDropdown.tsx @@ -16,7 +16,7 @@ type LinkProps = { const DevelopersDropdown = () => (
    -
    +
    {DevelopersData['navigation'].map((column) => (
    -
    -
    +
    +
    { Customer Stories -
      - {CustomersData.slice(0, isTablet ? 2 : 1).map((customer) => ( +
        + {CustomersData.slice(0, isTablet ? 1 : 1).map((customer) => (
      • {
    -
    -
    +
    +

    {ComparisonsData.label}

    @@ -177,6 +178,23 @@ const ProductDropdown = () => { ))}
    +
    +

    + {SolutionsData.label} +

    +
      + {SolutionsData.solutions.map((link) => ( +
    • + +
    • + ))} +
    +
    diff --git a/apps/www/components/Sections/ProductHeader2.tsx b/apps/www/components/Sections/ProductHeader2.tsx index cb00086d51..cfc2182c94 100644 --- a/apps/www/components/Sections/ProductHeader2.tsx +++ b/apps/www/components/Sections/ProductHeader2.tsx @@ -5,6 +5,8 @@ import ProductIcon from '../ProductIcon' import SectionContainer from '../Layouts/SectionContainer' import { CTA } from '~/types/common' +// to do: move types to be global +// then solutions.types.ts should extend this interface Props { label?: string | React.ReactNode h1: string | React.ReactNode @@ -70,7 +72,7 @@ const ProductHeader = (props: Props) => ( )}
    {props.image && ( -
    +
    {props.image}
    )} diff --git a/apps/www/components/Solutions/AIBuildersLogos.tsx b/apps/www/components/Solutions/AIBuildersLogos.tsx new file mode 100644 index 0000000000..c3b216997e --- /dev/null +++ b/apps/www/components/Solutions/AIBuildersLogos.tsx @@ -0,0 +1,83 @@ +import Link from 'next/link' +import React from 'react' +import { cn } from 'ui' + +const logos = [ + { + image: `/images/logos/publicity/lovable.svg`, + alt: 'lovable', + name: 'lovable', + href: 'https://lovable.dev/', + }, + { + image: `/images/logos/publicity/bolt.svg`, + alt: 'bolt', + name: 'bolt', + href: 'https://bolt.new', + }, + { + image: `/images/logos/publicity/v0.svg`, + alt: 'v0', + name: 'v0', + href: 'https://v0.dev', + }, + + { + image: `/images/logos/publicity/tempo-labs.svg`, + alt: 'tempo labs', + name: 'tempo-labs', + href: 'https://www.tempo.new/', + }, + { + image: `/images/logos/publicity/gumloop.svg`, + alt: 'gumloop', + name: 'gumloop', + href: 'https://gumloop.com', + }, + { + image: `/images/logos/publicity/co-com.svg`, + alt: 'co.com', + name: 'co-com', + href: 'https://co.dev', + }, +] + +interface Props { + className?: string +} + +const EnterpriseLogos: React.FC = ({ className }) => { + return ( +
    + {logos.map((logo) => ( + + {logo.alt} + + ))} +
    + ) +} + +export default EnterpriseLogos diff --git a/apps/www/components/Solutions/CTAForm.tsx b/apps/www/components/Solutions/CTAForm.tsx new file mode 100644 index 0000000000..af43938abd --- /dev/null +++ b/apps/www/components/Solutions/CTAForm.tsx @@ -0,0 +1,43 @@ +import React, { FC } from 'react' +import { cn, TextLink } from 'ui' +import SectionContainer from '~/components/Layouts/SectionContainer' +import TalkToPartnershipTeamForm from '~/components/Forms/TalkToPartnershipTeamForm' + +interface Props {} + +const UseCases: FC = () => { + return ( + +
    +
    +

    + Talk to our +
    + partnership team +

    +

    + Explore custom pricing and infrastructure options. +

    +
    + +
    + + +
    + ) +} + +const ConnectCallout: FC<{ className?: string }> = ({ className }) => ( +
    +
    Connect your app to Supabase now
    +

    + Set up a Supabase OAuth app so your users can start interacting with their Supabase Project. +

    + +
    +) + +export default UseCases diff --git a/apps/www/components/Solutions/FeaturesGrid.tsx b/apps/www/components/Solutions/FeaturesGrid.tsx new file mode 100644 index 0000000000..798ecb5beb --- /dev/null +++ b/apps/www/components/Solutions/FeaturesGrid.tsx @@ -0,0 +1,65 @@ +import styles from './features-grid.module.css' +import React from 'react' +import { cn } from 'ui' +import Panel from '~/components/Panel' +import SectionContainer from '../Layouts/SectionContainer' + +export default function FeaturesGrid(props: any) { + return ( + +
    +

    {props.heading}

    +

    {props.subheading}

    +
    +
    + + + + +
    +
    + ) +} + +const Content = ({ card, innerClassName }: { card: any; innerClassName?: string }) => { + return ( + + {card.img && ( +
    + {card.img} +
    + )} +
    +

    {card.heading}

    +
    +

    {card.subheading}

    +
    +
    +
    + ) +} diff --git a/apps/www/components/Solutions/FeaturesSection.tsx b/apps/www/components/Solutions/FeaturesSection.tsx new file mode 100644 index 0000000000..94ab76257a --- /dev/null +++ b/apps/www/components/Solutions/FeaturesSection.tsx @@ -0,0 +1,46 @@ +import React, { FC } from 'react' + +import { cn } from 'ui' +import SectionContainer from '~/components/Layouts/SectionContainer' +import type { Feature, WhySection } from '~/data/solutions/solutions.types' + +const Support: FC = (props) => { + return ( + +
    + {props.label} +

    {props.heading}

    +
    +
      + {props.features?.map((feature, index) => )} +
    +
    + ) +} + +interface FeatureItemProps { + feature: Feature +} + +const FeatureItem: FC = ({ feature }) => { + const Icon = feature.icon + const iconSize = 7 + const iconWidth = `w-${iconSize}` + const iconHeight = `h-${iconSize}` + + return ( +
  • + {Icon && ( + + )} +
    + +
    +

    {feature.heading}

    +

    {feature.subheading}

    + {/* */} +
  • + ) +} + +export default Support diff --git a/apps/www/components/Solutions/Quotes.tsx b/apps/www/components/Solutions/Quotes.tsx new file mode 100644 index 0000000000..e0521bd75f --- /dev/null +++ b/apps/www/components/Solutions/Quotes.tsx @@ -0,0 +1,90 @@ +import 'swiper/css' + +import Link from 'next/link' +import { FC } from 'react' +import { Swiper, SwiperSlide } from 'swiper/react' + +import SectionContainer from '~/components/Layouts/SectionContainer' +import Panel from '~/components/Panel' + +import type { Quote, Quotes } from '~/data/solutions/solutions.types' +import Image from 'next/image' + +const Quotes: FC = (props) => ( +
    +
    + +
      + {props.items?.map((quote: Quote) => ( +
    • + +
    • + ))} +
    +
    + + {props.items?.map((quote: Quote, i: number) => ( + + + + ))} + +
    +
    +
    +
    +) + +const QuoteCard: FC = ({ quote, author, avatar, authorTitle }) => { + return ( + +
    + {quote} +
    + +
    + {author} +
    + {author} + {authorTitle && ( + + {authorTitle} + + )} +
    +
    +
    + ) +} + +export default Quotes diff --git a/apps/www/components/Solutions/Videos.tsx b/apps/www/components/Solutions/Videos.tsx new file mode 100644 index 0000000000..d56ffbb69a --- /dev/null +++ b/apps/www/components/Solutions/Videos.tsx @@ -0,0 +1,47 @@ +import React, { FC } from 'react' + +import SectionContainer from '~/components/Layouts/SectionContainer' +import Panel from '../Panel' +import type { Testimonials } from '~/data/solutions/solutions.types' + +export type Story = { + url: string + heading: string + subheading: string | JSX.Element +} + +const EnterpriseSecurity: FC = (props) => { + return ( + +
    +

    {props.heading}

    +
    +
      + +
      +