mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
## What kind of change does this PR introduce? Bug fix / design-system alignment for the legacy `Button` from `ui`. ## What is the current behavior? Omitting `variant` on the legacy `Button` falls back to brand-green `primary`. That makes accidental greens easy, and it is hard to spot the real main action on busy pages. ## What is the new behavior? - Legacy `Button` now defaults to neutral `default` - Intentional primary CTAs (create, save, submit, marketing CTAs, and matching `ButtonTooltip` usages) now set `variant="primary"` so their appearance is unchanged - Neutral actions that previously relied on the old fallback (cancel, close, back, dashboard nav, and similar) become grey/white - Design-system docs updated; regression tests cover the new default `Button_Shadcn_` is unchanged. It already uses its own CVA default. This is PR 1 of 2 in a stack. PR 2 drops now-redundant `variant="default"` props. ## To test Studio (http://localhost:8082): - `/sign-in`: Sign in stays green - Open a project → Database → Tables: New table stays green - Auth → Users → Invite: Invite user stays green; Cancel / dismiss controls stay neutral - Project Settings → General: edit a field so Cancel and Save appear. Cancel is neutral, Save is green Design system (http://localhost:3003): - Components → Button: default demo is neutral; primary demo is green; featured preview is the default variant Marketing (optional): - www header: Start your project stays green; logged-in Dashboard is neutral <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Style** - Buttons now default to a neutral style, while primary actions across Studio, documentation, marketing pages, forms, dialogs, and error states use prominent primary styling. - Updated button examples and previews clarify the distinction between default and primary variants. - Event registration now includes a directional arrow icon. - **Tests** - Added coverage confirming default button styling and explicit primary styling behave as expected. - Updated related test fixtures to use primary styling where appropriate. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
283 lines
8.1 KiB
TypeScript
283 lines
8.1 KiB
TypeScript
import { useSendTelemetryEvent } from '~/lib/telemetry'
|
|
import { CircleAlert } from 'lucide-react'
|
|
import Link from 'next/link'
|
|
import { FC, useEffect, useState } from 'react'
|
|
import { Alert, Button, cn, Input, Label, Separator, TextArea } from 'ui'
|
|
|
|
interface FormData {
|
|
firstName: string
|
|
secondName: string
|
|
companyEmail: string
|
|
}
|
|
|
|
interface FormItem {
|
|
type: 'text' | 'textarea'
|
|
label: string
|
|
placeholder: string
|
|
required: boolean
|
|
className?: string
|
|
component: typeof TextArea | typeof Input
|
|
}
|
|
|
|
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,
|
|
},
|
|
secondName: {
|
|
type: 'text',
|
|
label: 'Last Name',
|
|
placeholder: 'Last Name',
|
|
required: true,
|
|
className: 'col-span-full',
|
|
component: Input,
|
|
},
|
|
companyEmail: {
|
|
type: 'text',
|
|
label: 'Company Email',
|
|
placeholder: 'Company Email',
|
|
required: true,
|
|
className: '',
|
|
component: Input,
|
|
},
|
|
}
|
|
|
|
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<Props> = ({ className }) => {
|
|
const [formData, setFormData] = useState<FormData>(defaultFormValue)
|
|
const [honeypot, setHoneypot] = useState<string>('') // field to prevent spam
|
|
const [errors, setErrors] = useState<{ [key: string]: string }>({})
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
const [success, setSuccess] = useState<string | null>(null)
|
|
const [startTime, setStartTime] = useState<number>(0)
|
|
const sendTelemetryEvent = useSendTelemetryEvent()
|
|
|
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
const { name, value } = e.target
|
|
setErrors({})
|
|
setFormData((prev) => ({ ...prev, [name]: value }))
|
|
}
|
|
|
|
const handleReset = (e: React.MouseEvent<HTMLButtonElement, 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 (
|
|
<div
|
|
className={cn(
|
|
'flex flex-col gap-4 w-full items-center justify-center min-h-[300px]',
|
|
className
|
|
)}
|
|
>
|
|
<div className="border rounded-xl bg-surface-75 p-4 md:p-6 w-full lg:max-w-lg min-h-[200px] flex flex-col">
|
|
{success ? (
|
|
<div className="flex flex-col flex-1 w-full gap-4 items-center justify-center animate-fade-in">
|
|
<p className="text-center text-sm">{success}</p>
|
|
<Button onClick={handleReset}>Reset</Button>
|
|
</div>
|
|
) : (
|
|
<form
|
|
id="support-form"
|
|
className={cn('flex flex-col lg:grid lg:grid-cols-2 gap-4')}
|
|
onSubmit={handleSubmit}
|
|
>
|
|
{Object.entries(formConfig).map(([key, { component: Component, ...fieldValue }]) => {
|
|
const fieldKey = key as keyof FormData
|
|
|
|
return (
|
|
<div
|
|
key={key}
|
|
className={cn('flex flex-col col-span-full gap-y-2', fieldValue.className)}
|
|
>
|
|
<Label htmlFor={fieldKey} className="text-foreground-light flex justify-between">
|
|
{fieldValue.label}
|
|
<div
|
|
className={cn(
|
|
'flex flex-nowrap text-right gap-1 items-center text-xs leading-none transition-opacity opacity-0 text-foreground-muted',
|
|
errors[key as keyof FormData] && 'opacity-100 animate-fade-in'
|
|
)}
|
|
>
|
|
{errors[fieldKey]}
|
|
</div>
|
|
</Label>
|
|
<Component
|
|
type="text"
|
|
id={fieldKey}
|
|
name={fieldKey}
|
|
value={formData[fieldKey]}
|
|
onChange={handleChange}
|
|
placeholder={fieldValue.placeholder}
|
|
/>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{/* Spam prevention */}
|
|
<input
|
|
type="text"
|
|
name="honeypot"
|
|
value={honeypot}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setHoneypot(e.target.value)}
|
|
style={{ display: 'none' }}
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
<Separator className="col-span-full" />
|
|
<Button
|
|
variant="primary"
|
|
block
|
|
type="submit"
|
|
size="small"
|
|
className="col-span-full"
|
|
disabled={isSubmitting}
|
|
loading={isSubmitting}
|
|
onClick={() =>
|
|
sendTelemetryEvent({
|
|
action: 'request_demo_button_clicked',
|
|
properties: { buttonLocation: 'Enterprise Request Demo Form' },
|
|
})
|
|
}
|
|
>
|
|
Submit
|
|
</Button>
|
|
<p className="text-foreground-lighter text-sm col-span-full">
|
|
By submitting this form, I confirm that I have read and understood the{' '}
|
|
<Link href="/privacy" className="text-foreground hover:underline">
|
|
Privacy Policy
|
|
</Link>
|
|
.
|
|
</p>
|
|
{errors.general && (
|
|
<Alert className="flex gap-2 text-foreground text-sm col-span-full">
|
|
<CircleAlert className="w-3 h-3" /> <span>{errors.general}</span>
|
|
</Alert>
|
|
)}
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default TalkToPartnershipTeamForm
|