mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(careers): added a careers page (#1746)
* feat(careers): added a careers page * cleanup * revert hardcoded environment
This commit is contained in:
524
apps/sim/app/(landing)/careers/page.tsx
Normal file
524
apps/sim/app/(landing)/careers/page.tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { Loader2, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { quickValidateEmail } from '@/lib/email/validation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LegalLayout } from '@/app/(landing)/components'
|
||||
import { soehne } from '@/app/fonts/soehne/soehne'
|
||||
|
||||
const validateName = (name: string): string[] => {
|
||||
const errors: string[] = []
|
||||
if (!name || name.trim().length < 2) {
|
||||
errors.push('Name must be at least 2 characters')
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
const validateEmail = (email: string): string[] => {
|
||||
const errors: string[] = []
|
||||
if (!email || !email.trim()) {
|
||||
errors.push('Email is required')
|
||||
return errors
|
||||
}
|
||||
const validation = quickValidateEmail(email.trim().toLowerCase())
|
||||
if (!validation.isValid) {
|
||||
errors.push(validation.reason || 'Please enter a valid email address')
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
const validatePosition = (position: string): string[] => {
|
||||
const errors: string[] = []
|
||||
if (!position || position.trim().length < 2) {
|
||||
errors.push('Please specify the position you are interested in')
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
const validateLinkedIn = (url: string): string[] => {
|
||||
if (!url || url.trim() === '') return []
|
||||
const errors: string[] = []
|
||||
try {
|
||||
new URL(url)
|
||||
} catch {
|
||||
errors.push('Please enter a valid LinkedIn URL')
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
const validatePortfolio = (url: string): string[] => {
|
||||
if (!url || url.trim() === '') return []
|
||||
const errors: string[] = []
|
||||
try {
|
||||
new URL(url)
|
||||
} catch {
|
||||
errors.push('Please enter a valid portfolio URL')
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
const validateLocation = (location: string): string[] => {
|
||||
const errors: string[] = []
|
||||
if (!location || location.trim().length < 2) {
|
||||
errors.push('Please enter your location')
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
const validateMessage = (message: string): string[] => {
|
||||
const errors: string[] = []
|
||||
if (!message || message.trim().length < 50) {
|
||||
errors.push('Please tell us more about yourself (at least 50 characters)')
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
export default function CareersPage() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle')
|
||||
const [showErrors, setShowErrors] = useState(false)
|
||||
|
||||
// Form fields
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [position, setPosition] = useState('')
|
||||
const [linkedin, setLinkedin] = useState('')
|
||||
const [portfolio, setPortfolio] = useState('')
|
||||
const [experience, setExperience] = useState('')
|
||||
const [location, setLocation] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [resume, setResume] = useState<File | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Field errors
|
||||
const [nameErrors, setNameErrors] = useState<string[]>([])
|
||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||
const [positionErrors, setPositionErrors] = useState<string[]>([])
|
||||
const [linkedinErrors, setLinkedinErrors] = useState<string[]>([])
|
||||
const [portfolioErrors, setPortfolioErrors] = useState<string[]>([])
|
||||
const [experienceErrors, setExperienceErrors] = useState<string[]>([])
|
||||
const [locationErrors, setLocationErrors] = useState<string[]>([])
|
||||
const [messageErrors, setMessageErrors] = useState<string[]>([])
|
||||
const [resumeErrors, setResumeErrors] = useState<string[]>([])
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0] || null
|
||||
setResume(file)
|
||||
if (file) {
|
||||
setResumeErrors([])
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setShowErrors(true)
|
||||
|
||||
// Validate all fields
|
||||
const nameErrs = validateName(name)
|
||||
const emailErrs = validateEmail(email)
|
||||
const positionErrs = validatePosition(position)
|
||||
const linkedinErrs = validateLinkedIn(linkedin)
|
||||
const portfolioErrs = validatePortfolio(portfolio)
|
||||
const experienceErrs = experience ? [] : ['Please select your years of experience']
|
||||
const locationErrs = validateLocation(location)
|
||||
const messageErrs = validateMessage(message)
|
||||
const resumeErrs = resume ? [] : ['Resume is required']
|
||||
|
||||
setNameErrors(nameErrs)
|
||||
setEmailErrors(emailErrs)
|
||||
setPositionErrors(positionErrs)
|
||||
setLinkedinErrors(linkedinErrs)
|
||||
setPortfolioErrors(portfolioErrs)
|
||||
setExperienceErrors(experienceErrs)
|
||||
setLocationErrors(locationErrs)
|
||||
setMessageErrors(messageErrs)
|
||||
setResumeErrors(resumeErrs)
|
||||
|
||||
if (
|
||||
nameErrs.length > 0 ||
|
||||
emailErrs.length > 0 ||
|
||||
positionErrs.length > 0 ||
|
||||
linkedinErrs.length > 0 ||
|
||||
portfolioErrs.length > 0 ||
|
||||
experienceErrs.length > 0 ||
|
||||
locationErrs.length > 0 ||
|
||||
messageErrs.length > 0 ||
|
||||
resumeErrs.length > 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setSubmitStatus('idle')
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('name', name)
|
||||
formData.append('email', email)
|
||||
formData.append('phone', phone || '')
|
||||
formData.append('position', position)
|
||||
formData.append('linkedin', linkedin || '')
|
||||
formData.append('portfolio', portfolio || '')
|
||||
formData.append('experience', experience)
|
||||
formData.append('location', location)
|
||||
formData.append('message', message)
|
||||
if (resume) formData.append('resume', resume)
|
||||
|
||||
const response = await fetch('/api/careers/submit', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to submit application')
|
||||
}
|
||||
|
||||
setSubmitStatus('success')
|
||||
} catch (error) {
|
||||
console.error('Error submitting application:', error)
|
||||
setSubmitStatus('error')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<LegalLayout title='Join Our Team'>
|
||||
<div className={`${soehne.className} mx-auto max-w-2xl`}>
|
||||
{/* Form Section */}
|
||||
<section className='rounded-2xl border border-gray-200 bg-white p-6 shadow-sm sm:p-10'>
|
||||
<h2 className='mb-2 font-medium text-2xl sm:text-3xl'>Apply Now</h2>
|
||||
<p className='mb-8 text-gray-600 text-sm sm:text-base'>
|
||||
Help us build the future of AI workflows
|
||||
</p>
|
||||
|
||||
<form onSubmit={onSubmit} className='space-y-5'>
|
||||
{/* Name and Email */}
|
||||
<div className='grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='name' className='font-medium text-sm'>
|
||||
Full Name *
|
||||
</Label>
|
||||
<Input
|
||||
id='name'
|
||||
placeholder='John Doe'
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={cn(
|
||||
showErrors &&
|
||||
nameErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{showErrors && nameErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{nameErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='email' className='font-medium text-sm'>
|
||||
Email *
|
||||
</Label>
|
||||
<Input
|
||||
id='email'
|
||||
type='email'
|
||||
placeholder='john@example.com'
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={cn(
|
||||
showErrors &&
|
||||
emailErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{showErrors && emailErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{emailErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone and Position */}
|
||||
<div className='grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='phone' className='font-medium text-sm'>
|
||||
Phone Number
|
||||
</Label>
|
||||
<Input
|
||||
id='phone'
|
||||
type='tel'
|
||||
placeholder='+1 (555) 123-4567'
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='position' className='font-medium text-sm'>
|
||||
Position of Interest *
|
||||
</Label>
|
||||
<Input
|
||||
id='position'
|
||||
placeholder='e.g. Full Stack Engineer, Product Designer'
|
||||
value={position}
|
||||
onChange={(e) => setPosition(e.target.value)}
|
||||
className={cn(
|
||||
showErrors &&
|
||||
positionErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{showErrors && positionErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{positionErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LinkedIn and Portfolio */}
|
||||
<div className='grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='linkedin' className='font-medium text-sm'>
|
||||
LinkedIn Profile
|
||||
</Label>
|
||||
<Input
|
||||
id='linkedin'
|
||||
placeholder='https://linkedin.com/in/yourprofile'
|
||||
value={linkedin}
|
||||
onChange={(e) => setLinkedin(e.target.value)}
|
||||
className={cn(
|
||||
showErrors &&
|
||||
linkedinErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{showErrors && linkedinErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{linkedinErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='portfolio' className='font-medium text-sm'>
|
||||
Portfolio / Website
|
||||
</Label>
|
||||
<Input
|
||||
id='portfolio'
|
||||
placeholder='https://yourportfolio.com'
|
||||
value={portfolio}
|
||||
onChange={(e) => setPortfolio(e.target.value)}
|
||||
className={cn(
|
||||
showErrors &&
|
||||
portfolioErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{showErrors && portfolioErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{portfolioErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Experience and Location */}
|
||||
<div className='grid grid-cols-1 gap-4 sm:gap-6 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='experience' className='font-medium text-sm'>
|
||||
Years of Experience *
|
||||
</Label>
|
||||
<Select value={experience} onValueChange={setExperience}>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
showErrors &&
|
||||
experienceErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder='Select experience level' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='0-1'>0-1 years</SelectItem>
|
||||
<SelectItem value='1-3'>1-3 years</SelectItem>
|
||||
<SelectItem value='3-5'>3-5 years</SelectItem>
|
||||
<SelectItem value='5-10'>5-10 years</SelectItem>
|
||||
<SelectItem value='10+'>10+ years</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{showErrors && experienceErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{experienceErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='location' className='font-medium text-sm'>
|
||||
Location *
|
||||
</Label>
|
||||
<Input
|
||||
id='location'
|
||||
placeholder='e.g. San Francisco, CA'
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
className={cn(
|
||||
showErrors &&
|
||||
locationErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
{showErrors && locationErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{locationErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='message' className='font-medium text-sm'>
|
||||
Tell us about yourself *
|
||||
</Label>
|
||||
<Textarea
|
||||
id='message'
|
||||
placeholder='Tell us about your experience, what excites you about Sim, and why you would be a great fit for this role...'
|
||||
className={cn(
|
||||
'min-h-[140px]',
|
||||
showErrors &&
|
||||
messageErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
<p className='mt-1.5 text-gray-500 text-xs'>Minimum 50 characters</p>
|
||||
{showErrors && messageErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{messageErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Resume Upload */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='resume' className='font-medium text-sm'>
|
||||
Resume *
|
||||
</Label>
|
||||
<div className='relative'>
|
||||
{resume ? (
|
||||
<div className='flex items-center gap-2 rounded-md border border-input bg-background px-3 py-2'>
|
||||
<span className='flex-1 truncate text-sm'>{resume.name}</span>
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setResume(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}}
|
||||
className='flex-shrink-0 text-muted-foreground transition-colors hover:text-foreground'
|
||||
aria-label='Remove file'
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
id='resume'
|
||||
type='file'
|
||||
accept='.pdf,.doc,.docx'
|
||||
onChange={handleFileChange}
|
||||
ref={fileInputRef}
|
||||
className={cn(
|
||||
showErrors &&
|
||||
resumeErrors.length > 0 &&
|
||||
'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className='mt-1.5 text-gray-500 text-xs'>PDF or Word document, max 10MB</p>
|
||||
{showErrors && resumeErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-red-400 text-xs'>
|
||||
{resumeErrors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className='flex justify-end pt-2'>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={isSubmitting || submitStatus === 'success'}
|
||||
className='min-w-[200px] rounded-[10px] border border-[#6F3DFA] bg-gradient-to-b from-[#8357FF] to-[#6F3DFA] text-white shadow-[inset_0_2px_4px_0_#9B77FF] transition-all duration-300 hover:opacity-90 disabled:opacity-50'
|
||||
size='lg'
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||
Submitting...
|
||||
</>
|
||||
) : submitStatus === 'success' ? (
|
||||
'Submitted'
|
||||
) : (
|
||||
'Submit Application'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Additional Info */}
|
||||
<section className='mt-6 text-center text-gray-600 text-sm'>
|
||||
<p>
|
||||
Questions? Email us at{' '}
|
||||
<a
|
||||
href='mailto:careers@sim.ai'
|
||||
className='font-medium text-gray-900 underline transition-colors hover:text-gray-700'
|
||||
>
|
||||
careers@sim.ai
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</LegalLayout>
|
||||
)
|
||||
}
|
||||
@@ -228,6 +228,12 @@ export default function Footer({ fullWidth = false }: FooterProps) {
|
||||
>
|
||||
Changelog
|
||||
</Link>
|
||||
<Link
|
||||
href='/careers'
|
||||
className='text-[14px] text-muted-foreground transition-colors hover:text-foreground'
|
||||
>
|
||||
Careers
|
||||
</Link>
|
||||
<Link
|
||||
href='/privacy'
|
||||
target='_blank'
|
||||
|
||||
@@ -20,7 +20,7 @@ interface NavProps {
|
||||
}
|
||||
|
||||
export default function Nav({ hideAuthButtons = false, variant = 'landing' }: NavProps = {}) {
|
||||
const [githubStars, setGithubStars] = useState('16.3k')
|
||||
const [githubStars, setGithubStars] = useState('17.4k')
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isLoginHovered, setIsLoginHovered] = useState(false)
|
||||
const router = useRouter()
|
||||
@@ -113,7 +113,7 @@ export default function Nav({ hideAuthButtons = false, variant = 'landing' }: Na
|
||||
itemType='https://schema.org/SiteNavigationElement'
|
||||
>
|
||||
<div className='flex items-center gap-[34px]'>
|
||||
<Link href='/' aria-label={`${brand.name} home`} itemProp='url'>
|
||||
<Link href='/?from=nav' aria-label={`${brand.name} home`} itemProp='url'>
|
||||
<span itemProp='name' className='sr-only'>
|
||||
{brand.name} Home
|
||||
</span>
|
||||
|
||||
200
apps/sim/app/api/careers/submit/route.ts
Normal file
200
apps/sim/app/api/careers/submit/route.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { render } from '@react-email/components'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import CareersConfirmationEmail from '@/components/emails/careers-confirmation-email'
|
||||
import CareersSubmissionEmail from '@/components/emails/careers-submission-email'
|
||||
import { sendEmail } from '@/lib/email/mailer'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateRequestId } from '@/lib/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('CareersAPI')
|
||||
|
||||
// Max file size: 10MB
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
const ALLOWED_FILE_TYPES = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
]
|
||||
|
||||
const CareersSubmissionSchema = z.object({
|
||||
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||
email: z.string().email('Please enter a valid email address'),
|
||||
phone: z.string().optional(),
|
||||
position: z.string().min(2, 'Please specify the position you are interested in'),
|
||||
linkedin: z.string().url('Please enter a valid LinkedIn URL').optional().or(z.literal('')),
|
||||
portfolio: z.string().url('Please enter a valid portfolio URL').optional().or(z.literal('')),
|
||||
experience: z.enum(['0-1', '1-3', '3-5', '5-10', '10+']),
|
||||
location: z.string().min(2, 'Please enter your location'),
|
||||
message: z.string().min(50, 'Please tell us more about yourself (at least 50 characters)'),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
|
||||
// Extract form fields
|
||||
const data = {
|
||||
name: formData.get('name') as string,
|
||||
email: formData.get('email') as string,
|
||||
phone: formData.get('phone') as string,
|
||||
position: formData.get('position') as string,
|
||||
linkedin: formData.get('linkedin') as string,
|
||||
portfolio: formData.get('portfolio') as string,
|
||||
experience: formData.get('experience') as string,
|
||||
location: formData.get('location') as string,
|
||||
message: formData.get('message') as string,
|
||||
}
|
||||
|
||||
// Extract and validate resume file
|
||||
const resumeFile = formData.get('resume') as File | null
|
||||
if (!resumeFile) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Resume is required',
|
||||
errors: [{ path: ['resume'], message: 'Resume is required' }],
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (resumeFile.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Resume file size must be less than 10MB',
|
||||
errors: [{ path: ['resume'], message: 'File size must be less than 10MB' }],
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!ALLOWED_FILE_TYPES.includes(resumeFile.type)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Resume must be a PDF or Word document',
|
||||
errors: [{ path: ['resume'], message: 'File must be PDF or Word document' }],
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Convert file to base64 for email attachment
|
||||
const resumeBuffer = await resumeFile.arrayBuffer()
|
||||
const resumeBase64 = Buffer.from(resumeBuffer).toString('base64')
|
||||
|
||||
const validatedData = CareersSubmissionSchema.parse(data)
|
||||
|
||||
logger.info(`[${requestId}] Processing career application`, {
|
||||
name: validatedData.name,
|
||||
email: validatedData.email,
|
||||
position: validatedData.position,
|
||||
resumeSize: resumeFile.size,
|
||||
resumeType: resumeFile.type,
|
||||
})
|
||||
|
||||
const submittedDate = new Date()
|
||||
|
||||
const careersEmailHtml = await render(
|
||||
CareersSubmissionEmail({
|
||||
name: validatedData.name,
|
||||
email: validatedData.email,
|
||||
phone: validatedData.phone,
|
||||
position: validatedData.position,
|
||||
linkedin: validatedData.linkedin,
|
||||
portfolio: validatedData.portfolio,
|
||||
experience: validatedData.experience,
|
||||
location: validatedData.location,
|
||||
message: validatedData.message,
|
||||
submittedDate,
|
||||
})
|
||||
)
|
||||
|
||||
const confirmationEmailHtml = await render(
|
||||
CareersConfirmationEmail({
|
||||
name: validatedData.name,
|
||||
position: validatedData.position,
|
||||
submittedDate,
|
||||
})
|
||||
)
|
||||
|
||||
// Send email with resume attachment
|
||||
const careersEmailResult = await sendEmail({
|
||||
to: 'careers@sim.ai',
|
||||
subject: `New Career Application: ${validatedData.name} - ${validatedData.position}`,
|
||||
html: careersEmailHtml,
|
||||
emailType: 'transactional',
|
||||
replyTo: validatedData.email,
|
||||
attachments: [
|
||||
{
|
||||
filename: resumeFile.name,
|
||||
content: resumeBase64,
|
||||
contentType: resumeFile.type,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (!careersEmailResult.success) {
|
||||
logger.error(`[${requestId}] Failed to send email to careers@sim.ai`, {
|
||||
error: careersEmailResult.message,
|
||||
})
|
||||
throw new Error('Failed to submit application')
|
||||
}
|
||||
|
||||
const confirmationResult = await sendEmail({
|
||||
to: validatedData.email,
|
||||
subject: `Your Application to Sim - ${validatedData.position}`,
|
||||
html: confirmationEmailHtml,
|
||||
emailType: 'transactional',
|
||||
replyTo: validatedData.email,
|
||||
})
|
||||
|
||||
if (!confirmationResult.success) {
|
||||
logger.warn(`[${requestId}] Failed to send confirmation email to applicant`, {
|
||||
email: validatedData.email,
|
||||
error: confirmationResult.message,
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Career application submitted successfully`, {
|
||||
careersEmailSent: careersEmailResult.success,
|
||||
confirmationEmailSent: confirmationResult.success,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Application submitted successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
logger.warn(`[${requestId}] Invalid application data`, { errors: error.errors })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid application data',
|
||||
errors: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error processing career application:`, error)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message:
|
||||
'Failed to submit application. Please try again or email us directly at careers@sim.ai',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
// Force light mode for certain pages
|
||||
const forcedTheme =
|
||||
pathname === '/' ||
|
||||
pathname === '/homepage' ||
|
||||
pathname.startsWith('/login') ||
|
||||
pathname.startsWith('/signup') ||
|
||||
pathname.startsWith('/sso') ||
|
||||
@@ -18,6 +17,7 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
pathname.startsWith('/privacy') ||
|
||||
pathname.startsWith('/invite') ||
|
||||
pathname.startsWith('/verify') ||
|
||||
pathname.startsWith('/careers') ||
|
||||
pathname.startsWith('/changelog') ||
|
||||
pathname.startsWith('/chat') ||
|
||||
pathname.startsWith('/blog')
|
||||
|
||||
@@ -198,7 +198,7 @@ export function SettingsNavigation({
|
||||
})
|
||||
|
||||
const handleHomepageClick = () => {
|
||||
window.location.href = '/homepage'
|
||||
window.location.href = '/?from=settings'
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
153
apps/sim/components/emails/careers-confirmation-email.tsx
Normal file
153
apps/sim/components/emails/careers-confirmation-email.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import { format } from 'date-fns'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
import { baseStyles } from './base-styles'
|
||||
import EmailFooter from './footer'
|
||||
|
||||
interface CareersConfirmationEmailProps {
|
||||
name: string
|
||||
position: string
|
||||
submittedDate?: Date
|
||||
}
|
||||
|
||||
export const CareersConfirmationEmail = ({
|
||||
name,
|
||||
position,
|
||||
submittedDate = new Date(),
|
||||
}: CareersConfirmationEmailProps) => {
|
||||
const brand = getBrandConfig()
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Body style={baseStyles.main}>
|
||||
<Preview>Your application to {brand.name} has been received</Preview>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
|
||||
width='114'
|
||||
alt={brand.name}
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
<Row>
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
<Column style={baseStyles.sectionCenter} />
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.content}>
|
||||
<Text style={baseStyles.paragraph}>Hello {name},</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Thank you for your interest in joining the {brand.name} team! We've received your
|
||||
application for the <strong>{position}</strong> position.
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Our team carefully reviews every application and will get back to you within the next
|
||||
few weeks. If your qualifications match what we're looking for, we'll reach out to
|
||||
schedule an initial conversation.
|
||||
</Text>
|
||||
|
||||
<Section
|
||||
style={{
|
||||
marginTop: '24px',
|
||||
marginBottom: '24px',
|
||||
padding: '20px',
|
||||
backgroundColor: '#f9f9f9',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e5e5e5',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
margin: '0 0 12px 0',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: '#333333',
|
||||
}}
|
||||
>
|
||||
What Happens Next?
|
||||
</Text>
|
||||
<ul
|
||||
style={{
|
||||
margin: '0',
|
||||
padding: '0 0 0 20px',
|
||||
fontSize: '14px',
|
||||
color: '#333333',
|
||||
lineHeight: '1.8',
|
||||
}}
|
||||
>
|
||||
<li>Our team will review your application</li>
|
||||
<li>If you're a good fit, we'll reach out to schedule an interview</li>
|
||||
<li>We'll keep you updated throughout the process</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
In the meantime, feel free to explore our{' '}
|
||||
<a
|
||||
href='https://docs.sim.ai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
style={{ color: '#802FFF', textDecoration: 'none' }}
|
||||
>
|
||||
documentation
|
||||
</a>{' '}
|
||||
to learn more about what we're building, or check out our{' '}
|
||||
<a href={`${baseUrl}/blog`} style={{ color: '#802FFF', textDecoration: 'none' }}>
|
||||
blog
|
||||
</a>{' '}
|
||||
for the latest updates.
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Best regards,
|
||||
<br />
|
||||
The {brand.name} Team
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
...baseStyles.footerText,
|
||||
marginTop: '40px',
|
||||
textAlign: 'left',
|
||||
color: '#666666',
|
||||
}}
|
||||
>
|
||||
This confirmation was sent on {format(submittedDate, 'MMMM do, yyyy')} at{' '}
|
||||
{format(submittedDate, 'h:mm a')}.
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
|
||||
<EmailFooter baseUrl={baseUrl} />
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
export default CareersConfirmationEmail
|
||||
313
apps/sim/components/emails/careers-submission-email.tsx
Normal file
313
apps/sim/components/emails/careers-submission-email.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import { format } from 'date-fns'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { getBaseUrl } from '@/lib/urls/utils'
|
||||
import { baseStyles } from './base-styles'
|
||||
|
||||
interface CareersSubmissionEmailProps {
|
||||
name: string
|
||||
email: string
|
||||
phone?: string
|
||||
position: string
|
||||
linkedin?: string
|
||||
portfolio?: string
|
||||
experience: string
|
||||
location: string
|
||||
message: string
|
||||
submittedDate?: Date
|
||||
}
|
||||
|
||||
const getExperienceLabel = (experience: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
'0-1': '0-1 years',
|
||||
'1-3': '1-3 years',
|
||||
'3-5': '3-5 years',
|
||||
'5-10': '5-10 years',
|
||||
'10+': '10+ years',
|
||||
}
|
||||
return labels[experience] || experience
|
||||
}
|
||||
|
||||
export const CareersSubmissionEmail = ({
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
position,
|
||||
linkedin,
|
||||
portfolio,
|
||||
experience,
|
||||
location,
|
||||
message,
|
||||
submittedDate = new Date(),
|
||||
}: CareersSubmissionEmailProps) => {
|
||||
const brand = getBrandConfig()
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Body style={baseStyles.main}>
|
||||
<Preview>New Career Application from {name}</Preview>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
|
||||
width='114'
|
||||
alt={brand.name}
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
<Row>
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
<Column style={baseStyles.sectionCenter} />
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.content}>
|
||||
<Text style={{ ...baseStyles.paragraph, fontSize: '18px', fontWeight: 'bold' }}>
|
||||
New Career Application
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
A new career application has been submitted on{' '}
|
||||
{format(submittedDate, 'MMMM do, yyyy')} at {format(submittedDate, 'h:mm a')}.
|
||||
</Text>
|
||||
|
||||
{/* Applicant Information */}
|
||||
<Section
|
||||
style={{
|
||||
marginTop: '24px',
|
||||
marginBottom: '24px',
|
||||
padding: '20px',
|
||||
backgroundColor: '#f9f9f9',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e5e5e5',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
margin: '0 0 16px 0',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: '#333333',
|
||||
}}
|
||||
>
|
||||
Applicant Information
|
||||
</Text>
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#666666',
|
||||
width: '40%',
|
||||
}}
|
||||
>
|
||||
Name:
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>{name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#666666',
|
||||
}}
|
||||
>
|
||||
Email:
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
|
||||
<a
|
||||
href={`mailto:${email}`}
|
||||
style={{ color: '#802FFF', textDecoration: 'none' }}
|
||||
>
|
||||
{email}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{phone && (
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#666666',
|
||||
}}
|
||||
>
|
||||
Phone:
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
|
||||
<a href={`tel:${phone}`} style={{ color: '#802FFF', textDecoration: 'none' }}>
|
||||
{phone}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#666666',
|
||||
}}
|
||||
>
|
||||
Position:
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
|
||||
{position}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#666666',
|
||||
}}
|
||||
>
|
||||
Experience:
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
|
||||
{getExperienceLabel(experience)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#666666',
|
||||
}}
|
||||
>
|
||||
Location:
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
|
||||
{location}
|
||||
</td>
|
||||
</tr>
|
||||
{linkedin && (
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#666666',
|
||||
}}
|
||||
>
|
||||
LinkedIn:
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
|
||||
<a
|
||||
href={linkedin}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
style={{ color: '#802FFF', textDecoration: 'none' }}
|
||||
>
|
||||
View Profile
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{portfolio && (
|
||||
<tr>
|
||||
<td
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#666666',
|
||||
}}
|
||||
>
|
||||
Portfolio:
|
||||
</td>
|
||||
<td style={{ padding: '8px 0', fontSize: '14px', color: '#333333' }}>
|
||||
<a
|
||||
href={portfolio}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
style={{ color: '#802FFF', textDecoration: 'none' }}
|
||||
>
|
||||
View Portfolio
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</table>
|
||||
</Section>
|
||||
|
||||
{/* Message */}
|
||||
<Section
|
||||
style={{
|
||||
marginTop: '24px',
|
||||
marginBottom: '24px',
|
||||
padding: '20px',
|
||||
backgroundColor: '#f9f9f9',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e5e5e5',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
margin: '0 0 12px 0',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: '#333333',
|
||||
}}
|
||||
>
|
||||
About Themselves
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
margin: '0',
|
||||
fontSize: '14px',
|
||||
color: '#333333',
|
||||
lineHeight: '1.6',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Please review this application and reach out to the candidate at your earliest
|
||||
convenience.
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
export default CareersSubmissionEmail
|
||||
@@ -23,7 +23,7 @@ function handleRootPathRedirects(
|
||||
): NextResponse | null {
|
||||
const url = request.nextUrl
|
||||
|
||||
if (url.pathname !== '/' && url.pathname !== '/homepage') {
|
||||
if (url.pathname !== '/') {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -35,14 +35,14 @@ function handleRootPathRedirects(
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
}
|
||||
|
||||
// Hosted: Allow access to /homepage route even for authenticated users
|
||||
if (url.pathname === '/homepage') {
|
||||
return NextResponse.rewrite(new URL('/', request.url))
|
||||
}
|
||||
|
||||
// For root path, redirect authenticated users to workspace
|
||||
if (hasActiveSession && url.pathname === '/') {
|
||||
return NextResponse.redirect(new URL('/workspace', request.url))
|
||||
// Unless they have a 'from' query parameter (e.g., ?from=nav, ?from=settings)
|
||||
// This allows intentional navigation to the homepage from anywhere in the app
|
||||
if (hasActiveSession) {
|
||||
const from = url.searchParams.get('from')
|
||||
if (!from) {
|
||||
return NextResponse.redirect(new URL('/workspace', request.url))
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
Reference in New Issue
Block a user