first commit
Made-with: Cursor
This commit is contained in:
24
src/App.tsx
Normal file
24
src/App.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { SiteLayout } from './layouts/SiteLayout'
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { HousePage } from './pages/HousePage'
|
||||
import { ApplyPage } from './pages/ApplyPage'
|
||||
import { CheckoutEmbeddedPage } from './pages/CheckoutEmbeddedPage'
|
||||
import { CheckoutReturnPage } from './pages/CheckoutReturnPage'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<SiteLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/homes/:slug" element={<HousePage />} />
|
||||
<Route path="/apply" element={<ApplyPage />} />
|
||||
<Route path="/apply/checkout" element={<CheckoutEmbeddedPage />} />
|
||||
<Route path="/apply/return" element={<CheckoutReturnPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
48
src/components/AnimatedButton.tsx
Normal file
48
src/components/AnimatedButton.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
const base =
|
||||
'relative inline-flex items-center justify-center overflow-hidden rounded-full px-6 py-3 text-sm font-semibold tracking-wide transition focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-300/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a1220] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
||||
export const ctaGoldClass =
|
||||
`${base} btn-shimmer bg-gradient-to-r from-amber-200/90 via-amber-300 to-amber-100/90 text-[#1a1408] shadow-[0_12px_40px_rgba(201,162,39,0.35)]`
|
||||
|
||||
export const ctaMistClass =
|
||||
`${base} bg-white/12 text-white ring-1 ring-white/25 hover:bg-white/18`
|
||||
|
||||
export const ctaGhostClass =
|
||||
`${base} bg-transparent text-white/90 ring-1 ring-white/20 hover:bg-white/10`
|
||||
|
||||
type Props = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
'onDrag' | 'onDragStart' | 'onDragEnd' | 'onAnimationStart' | 'onAnimationEnd'
|
||||
> & {
|
||||
children: ReactNode
|
||||
variant?: 'gold' | 'mist' | 'ghost'
|
||||
}
|
||||
|
||||
export function AnimatedButton({
|
||||
children,
|
||||
className = '',
|
||||
variant = 'gold',
|
||||
...rest
|
||||
}: Props) {
|
||||
const variants: Record<NonNullable<Props['variant']>, string> = {
|
||||
gold: ctaGoldClass,
|
||||
mist: ctaMistClass,
|
||||
ghost: ctaGhostClass,
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
whileHover={{ scale: 1.02, y: -1 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ type: 'spring', stiffness: 420, damping: 24 }}
|
||||
className={`${variants[variant]} ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
<span className="relative z-10">{children}</span>
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
99
src/components/HouseCard.tsx
Normal file
99
src/components/HouseCard.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import type { House } from '../types'
|
||||
|
||||
type Props = {
|
||||
house: House
|
||||
index: number
|
||||
}
|
||||
|
||||
const chipParent = {
|
||||
hidden: {},
|
||||
show: {
|
||||
transition: { staggerChildren: 0.09, delayChildren: 0.08 },
|
||||
},
|
||||
}
|
||||
|
||||
const chip = {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.4, ease: [0.22, 1, 0.36, 1] as const },
|
||||
},
|
||||
}
|
||||
|
||||
export function HouseCard({ house, index }: Props) {
|
||||
const cover = house.images[0]
|
||||
const half = house.halfBaths ? ` + ${house.halfBaths} half` : ''
|
||||
|
||||
return (
|
||||
<motion.article
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-40px' }}
|
||||
transition={{ duration: 0.45, delay: Math.min(index * 0.04, 0.4) }}
|
||||
className="group"
|
||||
>
|
||||
<Link
|
||||
to={`/homes/${house.slug}`}
|
||||
className="glass-panel block overflow-hidden rounded-2xl text-left transition hover:-translate-y-0.5 hover:shadow-[0_24px_80px_rgba(0,0,0,0.45)]"
|
||||
>
|
||||
<div className="relative aspect-[4/3] overflow-hidden">
|
||||
<img
|
||||
src={cover}
|
||||
alt={`${house.name} — exterior`}
|
||||
className="h-full w-full object-cover transition duration-700 group-hover:scale-[1.04]"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#0a1220]/85 via-transparent to-transparent" />
|
||||
<div className="absolute bottom-3 left-3 right-3 flex items-end justify-between gap-2">
|
||||
<p className="font-display text-xl font-semibold text-white text-balance sm:text-2xl">
|
||||
{house.name}
|
||||
</p>
|
||||
<span className="rounded-full bg-white/15 px-2.5 py-1 text-xs font-medium text-white/95 ring-1 ring-white/25 backdrop-blur">
|
||||
{house.neighborhood}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 px-4 py-4">
|
||||
<p className="text-[0.68rem] font-medium uppercase leading-snug tracking-[0.08em] text-amber-200/75">
|
||||
{house.area}
|
||||
</p>
|
||||
<p className="text-sm text-white/72">{house.tagline}</p>
|
||||
<motion.div
|
||||
className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-white/58"
|
||||
variants={chipParent}
|
||||
initial="hidden"
|
||||
whileInView="show"
|
||||
viewport={{ once: true, margin: '-20px' }}
|
||||
>
|
||||
<motion.span variants={chip}>
|
||||
{house.beds} bed{house.beds === 1 ? '' : 's'}
|
||||
</motion.span>
|
||||
<motion.span variants={chip} className="text-white/25" aria-hidden>
|
||||
✦
|
||||
</motion.span>
|
||||
<motion.span variants={chip}>
|
||||
{house.baths} bath{house.baths === 1 ? '' : 's'}
|
||||
{half}
|
||||
</motion.span>
|
||||
<motion.span variants={chip} className="text-white/25" aria-hidden>
|
||||
✦
|
||||
</motion.span>
|
||||
<motion.span variants={chip}>{house.sqft.toLocaleString()} sq ft</motion.span>
|
||||
</motion.div>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: 0.35, duration: 0.45 }}
|
||||
className="text-sm font-semibold text-amber-200/95"
|
||||
>
|
||||
${house.rent.toLocaleString()}
|
||||
<span className="font-normal text-white/50"> / mo</span>
|
||||
</motion.p>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.article>
|
||||
)
|
||||
}
|
||||
95
src/components/HouseSpecFade.tsx
Normal file
95
src/components/HouseSpecFade.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import type { House } from '../types'
|
||||
|
||||
const specVariants = {
|
||||
hidden: { opacity: 0, y: 10 },
|
||||
visible: (i: number) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { delay: 0.07 * i, duration: 0.45, ease: [0.22, 1, 0.36, 1] as const },
|
||||
}),
|
||||
}
|
||||
|
||||
const highlightVariants = {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
visible: (i: number) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { delay: 0.06 * i, duration: 0.4, ease: [0.22, 1, 0.36, 1] as const },
|
||||
}),
|
||||
}
|
||||
|
||||
type Row = { label: string; value: string; sub?: string; span?: 1 | 2 }
|
||||
|
||||
export function HouseSpecFade({ house }: { house: House }) {
|
||||
const half = house.halfBaths
|
||||
? ` + ${house.halfBaths} half`
|
||||
: ''
|
||||
const rows: Row[] = [
|
||||
{
|
||||
label: 'Seattle area',
|
||||
value: house.area,
|
||||
sub: house.neighborhood,
|
||||
span: 2,
|
||||
},
|
||||
{ label: 'Monthly offering', value: `$${house.rent.toLocaleString()}` },
|
||||
{ label: 'Availability', value: house.availableLabel },
|
||||
{
|
||||
label: 'Bedrooms & baths',
|
||||
value: `${house.beds} bed · ${house.baths} bath${half}`,
|
||||
},
|
||||
{ label: 'Room to breathe', value: `${house.sqft.toLocaleString()} sq ft` },
|
||||
{ label: 'Parking', value: house.parking, span: 2 },
|
||||
{ label: 'Pets', value: house.petPolicy, span: 2 },
|
||||
]
|
||||
|
||||
return (
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-4 text-sm">
|
||||
{rows.map((row, i) => (
|
||||
<motion.div
|
||||
key={row.label}
|
||||
custom={i}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={specVariants}
|
||||
className={row.span === 2 ? 'col-span-2' : ''}
|
||||
>
|
||||
<dt className="text-white/40">{row.label}</dt>
|
||||
<dd
|
||||
className={
|
||||
row.label === 'Monthly offering'
|
||||
? 'mt-1 text-lg font-semibold text-white'
|
||||
: 'mt-1 text-white/90'
|
||||
}
|
||||
>
|
||||
<span className="block text-pretty">{row.value}</span>
|
||||
{row.sub ? (
|
||||
<span className="mt-1 block text-sm text-pretty text-white/55">{row.sub}</span>
|
||||
) : null}
|
||||
</dd>
|
||||
</motion.div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
export function HighlightFade({ items }: { items: string[] }) {
|
||||
return (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{items.map((x, i) => (
|
||||
<motion.li
|
||||
key={x}
|
||||
custom={i}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true }}
|
||||
variants={highlightVariants}
|
||||
className="flex gap-2 text-white/75"
|
||||
>
|
||||
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-amber-300/75" />
|
||||
<span>{x}</span>
|
||||
</motion.li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
102
src/components/ImageGallery.tsx
Normal file
102
src/components/ImageGallery.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
|
||||
type Props = {
|
||||
images: string[]
|
||||
title: string
|
||||
}
|
||||
|
||||
export function ImageGallery({ images, title }: Props) {
|
||||
const [active, setActive] = useState(0)
|
||||
const [lightbox, setLightbox] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!lightbox) return
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setLightbox(false)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [lightbox])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="relative overflow-hidden rounded-2xl ring-1 ring-white/15">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLightbox(true)}
|
||||
className="group block w-full text-left"
|
||||
aria-label={`Open large photo of ${title}`}
|
||||
>
|
||||
<img
|
||||
src={images[active]}
|
||||
alt=""
|
||||
className="aspect-[16/10] w-full object-cover transition duration-500 group-hover:scale-[1.02]"
|
||||
/>
|
||||
<span className="absolute bottom-3 right-3 rounded-full bg-black/45 px-3 py-1 text-xs font-medium text-white/95 ring-1 ring-white/25 backdrop-blur">
|
||||
Expand
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 sm:gap-3">
|
||||
{images.map((src, i) => (
|
||||
<button
|
||||
key={src + i}
|
||||
type="button"
|
||||
onClick={() => setActive(i)}
|
||||
className={`relative h-16 w-24 shrink-0 overflow-hidden rounded-lg ring-2 transition sm:h-20 sm:w-32 ${
|
||||
i === active ? 'ring-amber-300/90' : 'ring-transparent hover:ring-white/25'
|
||||
}`}
|
||||
aria-label={`Show image ${i + 1}`}
|
||||
>
|
||||
<img src={src} alt="" className="h-full w-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{lightbox && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
role="dialog"
|
||||
aria-modal
|
||||
aria-label="Image gallery"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 cursor-zoom-out"
|
||||
onClick={() => setLightbox(false)}
|
||||
aria-label="Close gallery"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ scale: 0.96, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.96, opacity: 0 }}
|
||||
className="relative z-[101] max-h-[90vh] max-w-5xl overflow-hidden rounded-2xl shadow-2xl ring-1 ring-white/20"
|
||||
>
|
||||
<img
|
||||
src={images[active]}
|
||||
alt=""
|
||||
className="max-h-[90vh] w-full object-contain"
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-between gap-2 bg-gradient-to-t from-black/70 to-transparent p-4">
|
||||
<p className="truncate text-sm text-white/90">{title}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLightbox(false)}
|
||||
className="rounded-full bg-white/15 px-4 py-2 text-sm font-medium text-white ring-1 ring-white/30"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
src/components/MotionLink.tsx
Normal file
21
src/components/MotionLink.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Link, type LinkProps } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
const MotionLink = motion(Link)
|
||||
|
||||
type Props = Omit<
|
||||
LinkProps,
|
||||
'onDrag' | 'onDragStart' | 'onDragEnd' | 'onAnimationStart' | 'onAnimationEnd'
|
||||
>
|
||||
|
||||
export function MotionRouterLink({ className = '', ...rest }: Props) {
|
||||
return (
|
||||
<MotionLink
|
||||
whileHover={{ scale: 1.02, y: -1 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ type: 'spring', stiffness: 420, damping: 24 }}
|
||||
className={className}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
107
src/components/MountainBackdrop.tsx
Normal file
107
src/components/MountainBackdrop.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { WhispyEther } from './WhispyEther'
|
||||
|
||||
export function MountainBackdrop() {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0 z-0 overflow-hidden"
|
||||
aria-hidden
|
||||
>
|
||||
{/* Sky layers */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-[#1a3a5c] via-[#0f2744] to-[#0a1628]" />
|
||||
<div
|
||||
className="animate-mist absolute -left-[10%] top-[8%] h-[55%] w-[120%] opacity-40"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(ellipse at 30% 20%, rgba(126,184,218,0.45) 0%, transparent 55%), radial-gradient(ellipse at 70% 10%, rgba(255,255,255,0.12) 0%, transparent 50%)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 opacity-30 mix-blend-screen"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(circle at 50% 120%, rgba(30,61,50,0.55) 0%, transparent 45%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<WhispyEther />
|
||||
|
||||
{/* Distant mountains */}
|
||||
<svg
|
||||
className="absolute bottom-[18%] left-[-5%] w-[110%] text-[#1c2f3d] opacity-90"
|
||||
viewBox="0 0 1200 200"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M0,200 L0,120 L120,85 L220,110 L340,60 L460,95 L580,45 L720,80 L860,35 L980,70 L1100,50 L1200,90 L1200,200 Z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
className="absolute bottom-[14%] left-[-8%] w-[116%] text-[#152a38] opacity-95"
|
||||
viewBox="0 0 1200 220"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M0,220 L0,140 L100,115 L260,75 L400,105 L540,55 L700,95 L880,40 L1040,85 L1200,65 L1200,220 Z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Near ridge */}
|
||||
<svg
|
||||
className="absolute bottom-[10%] left-[-12%] w-[124%] text-[#0f1f2c]"
|
||||
viewBox="0 0 1200 260"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M0,260 L0,175 L140,130 L300,155 L480,95 L640,140 L820,70 L1000,120 L1200,90 L1200,260 Z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Forest silhouette */}
|
||||
<svg
|
||||
className="absolute bottom-0 left-0 w-full text-[#0b1a14]"
|
||||
viewBox="0 0 1200 120"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M0,120 L0,95 L15,88 L30,96 L48,78 L62,90 L80,72 L95,85 L115,65 L130,80 L150,58 L168,75 L185,62 L200,78 L220,55 L238,70 L255,60 L275,82 L295,52 L315,68 L335,48 L350,62 L370,45 L390,58 L410,42 L430,55 L450,38 L470,52 L490,35 L510,48 L530,32 L550,45 L570,30 L590,42 L610,28 L630,40 L650,26 L670,38 L690,24 L710,36 L730,22 L750,34 L770,20 L790,32 L810,18 L830,30 L850,16 L870,28 L890,14 L910,26 L930,12 L950,24 L970,10 L990,22 L1010,8 L1030,20 L1050,6 L1070,18 L1090,4 L1110,16 L1130,2 L1150,14 L1170,0 L1200,8 L1200,120 Z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Secondary tree line for depth */}
|
||||
<svg
|
||||
className="absolute bottom-0 left-[-4%] w-[108%] text-[#122820] opacity-80"
|
||||
viewBox="0 0 1200 100"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M0,100 L0,78 L25,70 L45,82 L70,60 L90,72 L115,55 L135,68 L160,50 L180,62 L205,48 L225,58 L250,42 L275,55 L300,38 L325,50 L350,35 L375,48 L400,32 L425,45 L450,30 L475,42 L500,28 L525,40 L550,26 L575,38 L600,24 L625,36 L650,22 L675,34 L700,20 L725,32 L750,18 L775,30 L800,16 L825,28 L850,14 L875,26 L900,12 L925,24 L950,10 L975,22 L1000,8 L1025,20 L1050,6 L1075,18 L1100,4 L1125,16 L1150,2 L1175,14 L1200,6 L1200,100 Z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Soft vignette */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(ellipse at 50% 20%, transparent 0%, rgba(5,10,18,0.55) 75%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Floating whimsical leaves */}
|
||||
<div className="animate-leaf absolute left-[8%] top-[22%] h-3 w-3 rotate-12 rounded-full bg-emerald-400/25 blur-[1px]" />
|
||||
<div
|
||||
className="animate-leaf absolute right-[14%] top-[30%] h-2 w-2 rotate-45 rounded-full bg-amber-200/20"
|
||||
style={{ animationDelay: '1.2s' }}
|
||||
/>
|
||||
<div
|
||||
className="animate-leaf absolute left-[22%] top-[38%] h-2.5 w-2.5 -rotate-6 rounded-full bg-sky-200/20"
|
||||
style={{ animationDelay: '2.4s' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
src/components/SiteHeader.tsx
Normal file
38
src/components/SiteHeader.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
const links = [
|
||||
{ to: { pathname: '/', hash: '#collection' }, label: 'Collection' },
|
||||
{ to: '/apply', label: 'Apply' },
|
||||
{ to: { pathname: '/', hash: '#concierge' }, label: 'Concierge' },
|
||||
]
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-white/10 bg-[#0a1220]/55 backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
|
||||
<Link to="/" className="group flex flex-col gap-0.5 sm:flex-row sm:items-baseline sm:gap-2">
|
||||
<span className="font-display text-2xl font-semibold tracking-tight text-white sm:text-[1.65rem]">
|
||||
Sacred Villas
|
||||
</span>
|
||||
<span className="text-[0.65rem] font-medium uppercase tracking-[0.32em] text-amber-200/55 sm:text-xs sm:tracking-[0.35em]">
|
||||
Seattle & surrounds
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex items-center gap-1 sm:gap-2" aria-label="Primary">
|
||||
{links.map((l) => (
|
||||
<motion.div key={l.label} whileHover={{ y: -1 }} whileTap={{ scale: 0.98 }}>
|
||||
<Link
|
||||
to={l.to}
|
||||
className="rounded-full px-3 py-2 text-sm font-medium text-white/80 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
107
src/components/WhispyEther.tsx
Normal file
107
src/components/WhispyEther.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/** Sets CSS vars --wisp-x / --wisp-y for parallax; interactive mist ribbons in the sky. */
|
||||
export function WhispyEther() {
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const nx = (e.clientX / window.innerWidth - 0.5) * 2
|
||||
const ny = (e.clientY / window.innerHeight - 0.5) * 2
|
||||
document.documentElement.style.setProperty('--wisp-x', `${nx}`)
|
||||
document.documentElement.style.setProperty('--wisp-y', `${ny}`)
|
||||
}
|
||||
window.addEventListener('mousemove', onMove, { passive: true })
|
||||
return () => window.removeEventListener('mousemove', onMove)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-[1] overflow-hidden"
|
||||
aria-hidden
|
||||
>
|
||||
{/* Long blurred ribbons — drift + mouse parallax via CSS */}
|
||||
<div
|
||||
className="wisp-ribbon wisp-ribbon-a absolute -left-[20%] top-[5%] h-[45vh] w-[140%] opacity-[0.38]"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(105deg, transparent 0%, rgba(180, 210, 255, 0.12) 25%, rgba(255, 220, 180, 0.08) 50%, transparent 75%)',
|
||||
filter: 'blur(42px)',
|
||||
transform:
|
||||
'translate(calc(var(--wisp-x, 0) * 28px), calc(var(--wisp-y, 0) * 18px)) rotate(-8deg)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="wisp-ribbon wisp-ribbon-b absolute -right-[15%] top-[18%] h-[38vh] w-[130%] opacity-[0.32]"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(95deg, transparent 10%, rgba(120, 200, 190, 0.1) 40%, rgba(230, 200, 255, 0.06) 65%, transparent 90%)',
|
||||
filter: 'blur(38px)',
|
||||
transform:
|
||||
'translate(calc(var(--wisp-x, 0) * -22px), calc(var(--wisp-y, 0) * 24px)) rotate(6deg)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="wisp-ribbon wisp-ribbon-c absolute left-[-10%] top-[32%] h-[32vh] w-[120%] opacity-[0.28]"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(ellipse 80% 50% at 50% 50%, rgba(255, 245, 220, 0.14) 0%, transparent 70%)',
|
||||
filter: 'blur(56px)',
|
||||
transform:
|
||||
'translate(calc(var(--wisp-x, 0) * 18px), calc(var(--wisp-y, 0) * -14px)) rotate(-3deg)',
|
||||
}}
|
||||
/>
|
||||
{/* Fine filaments */}
|
||||
<svg
|
||||
className="absolute left-0 top-[12%] h-[50vh] w-full opacity-[0.22] mix-blend-screen"
|
||||
style={{
|
||||
transform:
|
||||
'translate(calc(var(--wisp-x, 0) * 12px), calc(var(--wisp-y, 0) * 10px))',
|
||||
}}
|
||||
viewBox="0 0 1200 400"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="wisp-g1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="rgba(200, 230, 255, 0)" />
|
||||
<stop offset="40%" stopColor="rgba(200, 220, 255, 0.35)" />
|
||||
<stop offset="100%" stopColor="rgba(255, 200, 160, 0)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M0,180 Q200,120 400,200 T800,160 T1200,220 L1200,400 L0,400 Z"
|
||||
fill="url(#wisp-g1)"
|
||||
className="animate-wisp-path-a"
|
||||
/>
|
||||
<path
|
||||
d="M0,240 Q300,280 500,200 T900,260 T1200,180 L1200,400 L0,400 Z"
|
||||
fill="url(#wisp-g1)"
|
||||
opacity="0.5"
|
||||
className="animate-wisp-path-b"
|
||||
/>
|
||||
</svg>
|
||||
{/* Spark motes */}
|
||||
<div
|
||||
className="animate-wisp-mote absolute left-[12%] top-[20%] h-1 w-1 rounded-full bg-amber-100/40 blur-[1px]"
|
||||
style={{
|
||||
transform:
|
||||
'translate(calc(var(--wisp-x, 0) * 35px), calc(var(--wisp-y, 0) * 25px))',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="animate-wisp-mote absolute right-[20%] top-[28%] h-1.5 w-1.5 rounded-full bg-cyan-100/30 blur-[1px]"
|
||||
style={{
|
||||
animationDelay: '1.4s',
|
||||
transform:
|
||||
'translate(calc(var(--wisp-x, 0) * -28px), calc(var(--wisp-y, 0) * 20px))',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="animate-wisp-mote absolute left-[40%] top-[15%] h-1 w-1 rounded-full bg-violet-200/35 blur-[1px]"
|
||||
style={{
|
||||
animationDelay: '2.8s',
|
||||
transform:
|
||||
'translate(calc(var(--wisp-x, 0) * 20px), calc(var(--wisp-y, 0) * -18px))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
2
src/constants/checkout.ts
Normal file
2
src/constants/checkout.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** sessionStorage key for embedded checkout payload (Apply → /apply/checkout). */
|
||||
export const CHECKOUT_STORAGE_KEY = 'sv_checkout_v1'
|
||||
413
src/data/houses.ts
Normal file
413
src/data/houses.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
import type { House } from '../types'
|
||||
|
||||
/** Placeholder photography — replace URLs with your listing photos when ready. */
|
||||
const IMG = {
|
||||
a: 'https://images.unsplash.com/photo-1600596542815-ffad4c1539a9?auto=format&fit=crop&w=1600&q=80',
|
||||
b: 'https://images.unsplash.com/photo-1600585154340-be6161a56a0c?auto=format&fit=crop&w=1600&q=80',
|
||||
c: 'https://images.unsplash.com/photo-1564013799919-ab600027ffc6?auto=format&fit=crop&w=1600&q=80',
|
||||
d: 'https://images.unsplash.com/photo-1600607687939-ce8a6c77418c?auto=format&fit=crop&w=1600&q=80',
|
||||
e: 'https://images.unsplash.com/photo-1600210492496-0940d8c0a28c?auto=format&fit=crop&w=1600&q=80',
|
||||
f: 'https://images.unsplash.com/photo-1600566753190-b4f4bc296750?auto=format&fit=crop&w=1600&q=80',
|
||||
g: 'https://images.unsplash.com/photo-1600585154526-990dced4db0d?auto=format&fit=crop&w=1600&q=80',
|
||||
h: 'https://images.unsplash.com/photo-1600607687644-aac4c3eac7f4?auto=format&fit=crop&w=1600&q=80',
|
||||
i: 'https://images.unsplash.com/photo-1600566752355-35792bedcfea?auto=format&fit=crop&w=1600&q=80',
|
||||
j: 'https://images.unsplash.com/photo-1600573472550-8090b5e0746e?auto=format&fit=crop&w=1600&q=80',
|
||||
}
|
||||
|
||||
export const houses: House[] = [
|
||||
{
|
||||
id: 'cc-01',
|
||||
slug: 'willowgate-ballard',
|
||||
name: 'The Willowgate Residence',
|
||||
neighborhood: 'Ballard',
|
||||
area: 'Northwest Seattle · Ballard & Shilshole',
|
||||
tagline: 'Sun writes slow poems on oak; the locks are a breath away.',
|
||||
description:
|
||||
'A hush of craftsman calm where the kitchen feels like an altar to shared meals and the garden patio exhales cedar at dusk. West light pools on the floor; Ballard evenings ask only that you stay present for the fade.',
|
||||
beds: 3,
|
||||
baths: 2,
|
||||
sqft: 1840,
|
||||
rent: 4200,
|
||||
availableLabel: 'Available — mid-May',
|
||||
images: [IMG.a, IMG.e, IMG.f, IMG.j],
|
||||
highlights: ['Fenced garden patio', 'Walk to Ballard Ave', 'Detached studio-ready flex room'],
|
||||
petPolicy: 'Pets considered with deposit.',
|
||||
parking: '1 covered carport space',
|
||||
},
|
||||
{
|
||||
id: 'cc-02',
|
||||
slug: 'cedarlight-fremont',
|
||||
name: 'Cedarlight Townhome',
|
||||
neighborhood: 'Fremont',
|
||||
area: 'North Lake Union · Fremont & Ship Canal',
|
||||
tagline: 'Ship Canal sunsets from a rooftop that feels like a crow’s nest.',
|
||||
description:
|
||||
'Three breaths of vertical home — a stair that hums, bedrooms tucked in treetops, and a roof deck where Fremont’s sky bruises pink over the water. Gallery walls wait for your story; the city’s pulse stays just below the treeline.',
|
||||
beds: 3,
|
||||
baths: 2,
|
||||
halfBaths: 1,
|
||||
sqft: 2010,
|
||||
rent: 4800,
|
||||
availableLabel: 'Available — June 1',
|
||||
images: [IMG.b, IMG.g, IMG.h, IMG.a],
|
||||
highlights: ['Rooftop deck', 'EV-ready garage', 'Near Fremont Sunday Market'],
|
||||
petPolicy: 'Cats welcome; dogs on approval.',
|
||||
parking: '2 garage bays',
|
||||
},
|
||||
{
|
||||
id: 'cc-03',
|
||||
slug: 'ravenna-ridge-house',
|
||||
name: 'Ravenna Ridge House',
|
||||
neighborhood: 'Ravenna',
|
||||
area: 'Northeast Seattle · Ravenna & Bryant',
|
||||
tagline: 'Old-growth quiet, a kitchen that opens like a blessing.',
|
||||
description:
|
||||
'Tucked behind layered green, Ravenna offers rooms that feel both stately and soft. The kitchen spills onto a generous deck; the backyard holds silence for morning coffee and the rustle of something wilder than traffic.',
|
||||
beds: 4,
|
||||
baths: 3,
|
||||
sqft: 2650,
|
||||
rent: 5600,
|
||||
availableLabel: 'Available — now',
|
||||
images: [IMG.c, IMG.d, IMG.e, IMG.i],
|
||||
highlights: ['Two primary suites', 'Mature garden', 'Near Cowen Park'],
|
||||
petPolicy: 'Small dogs considered.',
|
||||
parking: 'Driveway + street permit zone',
|
||||
},
|
||||
{
|
||||
id: 'cc-04',
|
||||
slug: 'magnolia-view-estate',
|
||||
name: 'Magnolia View Estate',
|
||||
neighborhood: 'Magnolia',
|
||||
area: 'Magnolia & Interbay · Sound side',
|
||||
tagline: 'Salt on the breeze, evergreens holding the perimeter like a spell.',
|
||||
description:
|
||||
'Perched to catch layered views of water and sky, this Magnolia retreat wraps you in fir and hemlock. Inside, neutrals and wide plank invite barefoot wandering; the dining room waits for candlelit gatherings that end with laughter on the terrace.',
|
||||
beds: 4,
|
||||
baths: 3,
|
||||
halfBaths: 1,
|
||||
sqft: 3100,
|
||||
rent: 7200,
|
||||
availableLabel: 'Available — July 15',
|
||||
images: [IMG.d, IMG.a, IMG.h, IMG.b],
|
||||
highlights: ['View terraces', 'Wine storage nook', 'Near Discovery Park trails'],
|
||||
petPolicy: 'No pets.',
|
||||
parking: '3-car garage',
|
||||
},
|
||||
{
|
||||
id: 'cc-05',
|
||||
slug: 'queen-anne-carriage',
|
||||
name: 'Queen Anne Carriage Home',
|
||||
neighborhood: 'Queen Anne',
|
||||
area: 'Queen Anne & Uptown · Seattle Center edge',
|
||||
tagline: 'Kerry Park’s borrowed skyline — your living room borrows it back.',
|
||||
description:
|
||||
'Carriage-house grace: a gracious entry, a living room that holds conversation, and a kitchen that becomes the hearth of the house. Upper bedrooms are bright vessels for sleep — the city glitters politely at the edges.',
|
||||
beds: 3,
|
||||
baths: 2,
|
||||
sqft: 2200,
|
||||
rent: 5100,
|
||||
availableLabel: 'Available — late May',
|
||||
images: [IMG.g, IMG.e, IMG.c, IMG.j],
|
||||
highlights: ['City glimpses from primary suite', 'Walk score 90+', 'Original millwork details'],
|
||||
petPolicy: 'One pet under 35 lbs.',
|
||||
parking: '1 garage + storage',
|
||||
},
|
||||
{
|
||||
id: 'cc-06',
|
||||
slug: 'capitol-hill-row',
|
||||
name: 'Capitol Row Residence',
|
||||
neighborhood: 'Capitol Hill',
|
||||
area: 'Capitol Hill & Broadway · Central Seattle',
|
||||
tagline: 'Capitol pulse outside; a courtyard stillpoint within.',
|
||||
description:
|
||||
'Along a tree-lined street, this row hums with urban rhythm — then the gate closes and the courtyard holds a hush you can feel in your ribs. Interiors are crisp, contemporary, tuned for friends who stay too late.',
|
||||
beds: 2,
|
||||
baths: 2,
|
||||
halfBaths: 1,
|
||||
sqft: 1680,
|
||||
rent: 3950,
|
||||
availableLabel: 'Available — now',
|
||||
images: [IMG.h, IMG.f, IMG.b, IMG.d],
|
||||
highlights: ['Private courtyard', 'Near light rail', 'In-unit laundry'],
|
||||
petPolicy: 'Cats only.',
|
||||
parking: 'Tandem garage',
|
||||
},
|
||||
{
|
||||
id: 'cc-07',
|
||||
slug: 'green-lake-lantern',
|
||||
name: 'Green Lake Lantern House',
|
||||
neighborhood: 'Green Lake',
|
||||
area: 'Green Lake & North central',
|
||||
tagline: 'The lake’s small prayers — loops, paddles, morning mist.',
|
||||
description:
|
||||
'Built around rituals: coffee by the garden window, laptop hours in generous light, then sandals to the Green Lake loop. Finishes are soft enough for bare feet, strong enough for muddy dogs.',
|
||||
beds: 3,
|
||||
baths: 2,
|
||||
sqft: 1920,
|
||||
rent: 4450,
|
||||
availableLabel: 'Available — June 10',
|
||||
images: [IMG.a, IMG.i, IMG.e, IMG.g],
|
||||
highlights: ['8 min walk to the lake', 'Solar-ready roof', 'Heated bathroom floors'],
|
||||
petPolicy: 'Pets considered.',
|
||||
parking: '1 garage',
|
||||
},
|
||||
{
|
||||
id: 'cc-08',
|
||||
slug: 'wallingford-craftsman',
|
||||
name: 'Wallingford Craftsman',
|
||||
neighborhood: 'Wallingford',
|
||||
area: 'Wallingford & Northlake · near UW',
|
||||
tagline: 'Pocket doors, front-porch gossip with the trees.',
|
||||
description:
|
||||
'A craftsman soul with bones that remember another century — lovingly tended, systems quietly new. The main floor flows like a slow breath: living, dining, kitchen, then deck air scented with someone’s jasmine down the block.',
|
||||
beds: 3,
|
||||
baths: 2,
|
||||
sqft: 2100,
|
||||
rent: 4700,
|
||||
availableLabel: 'Available — now',
|
||||
images: [IMG.c, IMG.j, IMG.a, IMG.f],
|
||||
highlights: ['Front porch sitting', 'Walk to Wallingford center', 'Basement storage'],
|
||||
petPolicy: 'Dogs on approval.',
|
||||
parking: 'Driveway',
|
||||
},
|
||||
{
|
||||
id: 'cc-09',
|
||||
slug: 'west-seattle-bay-breeze',
|
||||
name: 'Bay Breeze Bungalow',
|
||||
neighborhood: 'West Seattle',
|
||||
area: 'West Seattle peninsula · bridge & beaches',
|
||||
tagline: 'Bridge life, beach weekends.',
|
||||
description:
|
||||
'Single-level living with an open kitchen, generous bedrooms, and a yard tuned for gardening. Commute-friendly with a neighborhood that feels like a getaway.',
|
||||
beds: 3,
|
||||
baths: 2,
|
||||
sqft: 1760,
|
||||
rent: 4100,
|
||||
availableLabel: 'Available — July 1',
|
||||
images: [IMG.b, IMG.d, IMG.h, IMG.e],
|
||||
highlights: ['Yard irrigation', 'Near Lincoln Park', 'Updated electrical'],
|
||||
petPolicy: 'Pets welcome with fee.',
|
||||
parking: '2 driveway spaces',
|
||||
},
|
||||
{
|
||||
id: 'cc-10',
|
||||
slug: 'phinney-ridge-terrace',
|
||||
name: 'Phinney Ridge Terrace',
|
||||
neighborhood: 'Phinney Ridge',
|
||||
area: 'Phinney & Greenwood · Woodland Park',
|
||||
tagline: 'Zoo mornings, woodland quiet.',
|
||||
description:
|
||||
'Terraced landscaping creates outdoor rooms for dining and lounging. Inside, the plan favors clarity: wide hallways, bright bedrooms, and a kitchen built for batch cooking.',
|
||||
beds: 4,
|
||||
baths: 2,
|
||||
halfBaths: 1,
|
||||
sqft: 2480,
|
||||
rent: 5200,
|
||||
availableLabel: 'Available — August 1',
|
||||
images: [IMG.d, IMG.g, IMG.c, IMG.i],
|
||||
highlights: ['Terraced gardens', 'Workshop nook', 'Near Woodland Park'],
|
||||
petPolicy: 'Cats welcome.',
|
||||
parking: 'Garage + street',
|
||||
},
|
||||
{
|
||||
id: 'cc-11',
|
||||
slug: 'columbia-city-station',
|
||||
name: 'Columbia City Station Home',
|
||||
neighborhood: 'Columbia City',
|
||||
area: 'Columbia City & Rainier Valley · South End',
|
||||
tagline: 'Light rail at your doorstep.',
|
||||
description:
|
||||
'A contemporary plan with strong indoor-outdoor flow. The main floor is social; upstairs bedrooms are restful. Perfect for commuters who still want a neighborhood heartbeat.',
|
||||
beds: 3,
|
||||
baths: 2,
|
||||
halfBaths: 1,
|
||||
sqft: 1980,
|
||||
rent: 4300,
|
||||
availableLabel: 'Available — now',
|
||||
images: [IMG.f, IMG.a, IMG.j, IMG.b],
|
||||
highlights: ['Steps to Link', 'Rooftop-ready structure', 'Community pocket park'],
|
||||
petPolicy: 'Pets considered.',
|
||||
parking: '1 garage',
|
||||
},
|
||||
{
|
||||
id: 'cc-12',
|
||||
slug: 'madrona-lakeside',
|
||||
name: 'Madrona Lakeside Retreat',
|
||||
neighborhood: 'Madrona',
|
||||
area: 'Madrona & Central District · Lake Washington',
|
||||
tagline: 'Lake Washington temperament, Madrona charm.',
|
||||
description:
|
||||
'Thoughtful updates honor the home’s character while improving everyday ease. Large windows frame greenery; the primary suite feels like a private suite in a boutique hotel.',
|
||||
beds: 4,
|
||||
baths: 3,
|
||||
sqft: 2880,
|
||||
rent: 6800,
|
||||
availableLabel: 'Available — June 20',
|
||||
images: [IMG.e, IMG.h, IMG.d, IMG.g],
|
||||
highlights: ['Lake proximity', 'Primary spa bath', 'Dedicated office'],
|
||||
petPolicy: 'No pets.',
|
||||
parking: '2-car garage',
|
||||
},
|
||||
{
|
||||
id: 'cc-13',
|
||||
slug: 'leschi-slope',
|
||||
name: 'Leschi Slope Residence',
|
||||
neighborhood: 'Leschi',
|
||||
area: 'Leschi & Judkins · Lake Washington',
|
||||
tagline: 'Sloped gardens, skyline sparks.',
|
||||
description:
|
||||
'Multi-level living with dramatic windows and a kitchen that anchors the home. Outdoor spaces are terraced for dining at different times of day.',
|
||||
beds: 3,
|
||||
baths: 2,
|
||||
halfBaths: 1,
|
||||
sqft: 2340,
|
||||
rent: 5400,
|
||||
availableLabel: 'Available — July 10',
|
||||
images: [IMG.i, IMG.c, IMG.a, IMG.f],
|
||||
highlights: ['Terraced outdoor dining', 'City glimpses', 'Near Leschi Marina'],
|
||||
petPolicy: 'One dog under 50 lbs.',
|
||||
parking: '2 garage',
|
||||
},
|
||||
{
|
||||
id: 'cc-14',
|
||||
slug: 'beacon-hill-garden',
|
||||
name: 'Beacon Hill Garden House',
|
||||
neighborhood: 'Beacon Hill',
|
||||
area: 'Beacon Hill & South End · Downtown views',
|
||||
tagline: 'City views without the noise.',
|
||||
description:
|
||||
'Perched for outlooks yet grounded with raised beds and fruit trees. Interiors are minimal and calm — a canvas for your own art and rhythm.',
|
||||
beds: 3,
|
||||
baths: 2,
|
||||
sqft: 1890,
|
||||
rent: 3900,
|
||||
availableLabel: 'Available — now',
|
||||
images: [IMG.j, IMG.b, IMG.e, IMG.h],
|
||||
highlights: ['Raised garden beds', 'View deck', 'Near Jefferson Park'],
|
||||
petPolicy: 'Cats welcome.',
|
||||
parking: 'Driveway',
|
||||
},
|
||||
{
|
||||
id: 'cc-15',
|
||||
slug: 'georgetown-loft-house',
|
||||
name: 'Georgetown Loft House',
|
||||
neighborhood: 'Georgetown',
|
||||
area: 'Georgetown & Duwamish · South Industrial',
|
||||
tagline: 'Industrial soul, residential comfort.',
|
||||
description:
|
||||
'Tall ceilings, generous glazing, and a layout that suits creatives and remote teams. The neighborhood’s art walks and cafes are part of the lifestyle.',
|
||||
beds: 2,
|
||||
baths: 2,
|
||||
sqft: 1650,
|
||||
rent: 3600,
|
||||
availableLabel: 'Available — May 25',
|
||||
images: [IMG.h, IMG.f, IMG.g, IMG.c],
|
||||
highlights: ['16\' ceilings in living', 'Near airport access', 'Artist studio nook'],
|
||||
petPolicy: 'Pets considered.',
|
||||
parking: 'Off-street pad',
|
||||
},
|
||||
{
|
||||
id: 'cc-16',
|
||||
slug: 'south-lake-union-sky',
|
||||
name: 'South Lake Union Sky Flat',
|
||||
neighborhood: 'South Lake Union',
|
||||
area: 'South Lake Union (SLU) · Lake Union core',
|
||||
tagline: 'Lake Union at your tempo.',
|
||||
description:
|
||||
'A polished residence for those who want Seattle’s innovation district at arm’s length. Finishes are contemporary; the building amenities extend your living space.',
|
||||
beds: 2,
|
||||
baths: 2,
|
||||
sqft: 1420,
|
||||
rent: 4500,
|
||||
availableLabel: 'Available — June 5',
|
||||
images: [IMG.g, IMG.d, IMG.i, IMG.a],
|
||||
highlights: ['Building gym + lounge', 'Walk to lakefront', 'Floor-to-ceiling glass'],
|
||||
petPolicy: 'Breed restrictions apply.',
|
||||
parking: 'Secured garage (1)',
|
||||
},
|
||||
{
|
||||
id: 'cc-17',
|
||||
slug: 'belltown-velvet',
|
||||
name: 'Belltown Velvet Residence',
|
||||
neighborhood: 'Belltown',
|
||||
area: 'Belltown & Downtown · Waterfront edge',
|
||||
tagline: 'Waterfront walks, midnight jazz.',
|
||||
description:
|
||||
'Urban living with soft edges: warm materials, layered lighting, and a layout that separates work from rest. Ideal for couples who love the city’s cultural spine.',
|
||||
beds: 2,
|
||||
baths: 2,
|
||||
sqft: 1380,
|
||||
rent: 4200,
|
||||
availableLabel: 'Available — now',
|
||||
images: [IMG.f, IMG.j, IMG.b, IMG.e],
|
||||
highlights: ['Near Sculpture Park', 'Quiet elevation', 'Concierge building'],
|
||||
petPolicy: 'Cats only.',
|
||||
parking: 'Leased nearby optional',
|
||||
},
|
||||
{
|
||||
id: 'cc-18',
|
||||
slug: 'eastlake-boathouse',
|
||||
name: 'Eastlake Boathouse Row',
|
||||
neighborhood: 'Eastlake',
|
||||
area: 'Eastlake & Lake Union · East of SLU',
|
||||
tagline: 'Wake to rowers, sleep to lapping water.',
|
||||
description:
|
||||
'A rare lakeside-adjacent home with character details and updated systems. Windows and decks are oriented toward water life without sacrificing privacy.',
|
||||
beds: 3,
|
||||
baths: 2,
|
||||
sqft: 2050,
|
||||
rent: 5800,
|
||||
availableLabel: 'Available — August 15',
|
||||
images: [IMG.e, IMG.i, IMG.d, IMG.h],
|
||||
highlights: ['Lake proximity', 'Wraparound deck', 'Kayak storage'],
|
||||
petPolicy: 'Pets on approval.',
|
||||
parking: '1 garage + street',
|
||||
},
|
||||
{
|
||||
id: 'cc-19',
|
||||
slug: 'laurelhurst-legacy',
|
||||
name: 'Laurelhurst Legacy Estate',
|
||||
neighborhood: 'Laurelhurst',
|
||||
area: 'Laurelhurst & Windermere · Northeast lake',
|
||||
tagline: 'Old Seattle grace, new systems throughout.',
|
||||
description:
|
||||
'Generous rooms, detailed millwork, and a kitchen designed for gatherings. The lot offers mature trees and space for outdoor living at a rare scale.',
|
||||
beds: 5,
|
||||
baths: 4,
|
||||
sqft: 4200,
|
||||
rent: 9500,
|
||||
availableLabel: 'Available — September 1',
|
||||
images: [IMG.d, IMG.c, IMG.g, IMG.j],
|
||||
highlights: ['Estate-scale lot', 'Guest suite wing', 'Near Laurelhurst Beach Club'],
|
||||
petPolicy: 'No pets.',
|
||||
parking: '4-car motor court',
|
||||
},
|
||||
{
|
||||
id: 'cc-20',
|
||||
slug: 'northgate-nest',
|
||||
name: 'Northgate Nest Townhome',
|
||||
neighborhood: 'Northgate',
|
||||
area: 'Northgate & North Seattle · Link & I-5',
|
||||
tagline: 'Light rail, trails, and a tidy modern plan.',
|
||||
description:
|
||||
'A practical luxury layout: three bedrooms, efficient storage, and community amenities that extend your square footage. Ideal for families balancing commute and weekend hikes.',
|
||||
beds: 3,
|
||||
baths: 2,
|
||||
halfBaths: 1,
|
||||
sqft: 1880,
|
||||
rent: 3800,
|
||||
availableLabel: 'Available — now',
|
||||
images: [IMG.a, IMG.f, IMG.b, IMG.g],
|
||||
highlights: ['Near Link + freeway', 'Community green', 'Smart thermostat package'],
|
||||
petPolicy: 'Pets welcome with deposit.',
|
||||
parking: '2 tandem garage',
|
||||
},
|
||||
]
|
||||
|
||||
export function getHouseBySlug(slug: string): House | undefined {
|
||||
return houses.find((h) => h.slug === slug)
|
||||
}
|
||||
|
||||
export function getHouseById(id: string): House | undefined {
|
||||
return houses.find((h) => h.id === id)
|
||||
}
|
||||
156
src/index.css
Normal file
156
src/index.css
Normal file
@@ -0,0 +1,156 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
--wisp-x: 0;
|
||||
--wisp-y: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100svh;
|
||||
@apply font-sans text-villa-mist bg-[#0a1220];
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100svh;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(201, 162, 39, 0.35);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@keyframes float-leaf {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) rotate(-2deg);
|
||||
opacity: 0.35;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-14px) rotate(3deg);
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer-btn {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes mist-drift {
|
||||
0% {
|
||||
transform: translateX(-2%) translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(2%) translateY(-1%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-2%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wisp-path-a {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wisp-path-b {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
transform: translateX(0);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.38;
|
||||
transform: translateX(18px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wisp-mote {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
transform: scale(1.4);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wisp-ribbon-drift {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.28;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.42;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-wisp-path-a {
|
||||
animation: wisp-path-a 22s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-wisp-path-b {
|
||||
animation: wisp-path-b 18s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-wisp-mote {
|
||||
animation: wisp-mote 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.wisp-ribbon-a {
|
||||
animation: wisp-ribbon-drift 26s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.wisp-ribbon-b {
|
||||
animation: wisp-ribbon-drift 32s ease-in-out infinite 1s;
|
||||
}
|
||||
|
||||
.wisp-ribbon-c {
|
||||
animation: wisp-ribbon-drift 20s ease-in-out infinite 0.5s;
|
||||
}
|
||||
|
||||
.animate-mist {
|
||||
animation: mist-drift 28s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-leaf {
|
||||
animation: float-leaf 9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.btn-shimmer {
|
||||
background-size: 200% 200%;
|
||||
animation: shimmer-btn 8s linear infinite;
|
||||
}
|
||||
|
||||
.glass-panel {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.1) 0%,
|
||||
rgba(255, 255, 255, 0.04) 50%,
|
||||
rgba(255, 255, 255, 0.08) 100%
|
||||
);
|
||||
backdrop-filter: blur(14px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
box-shadow:
|
||||
0 20px 50px rgba(0, 0, 0, 0.35),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
27
src/layouts/SiteLayout.tsx
Normal file
27
src/layouts/SiteLayout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { MountainBackdrop } from '../components/MountainBackdrop'
|
||||
import { SiteHeader } from '../components/SiteHeader'
|
||||
|
||||
export function SiteLayout() {
|
||||
return (
|
||||
<div className="relative min-h-svh">
|
||||
<MountainBackdrop />
|
||||
<div className="relative z-10 flex min-h-svh flex-col">
|
||||
<SiteHeader />
|
||||
<main className="flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
<footer className="border-t border-white/10 bg-[#070d18]/80 py-10 text-center text-sm text-white/45 backdrop-blur">
|
||||
<p className="font-display text-base text-white/75">Sacred Villas</p>
|
||||
<p className="mt-2 max-w-xl mx-auto px-4 text-pretty text-white/55">
|
||||
A wandering collection of Seattle-area homes — placeholder imagery until your own
|
||||
photographs arrive. Each address is a different spell of light.
|
||||
</p>
|
||||
<p className="mt-4 text-xs text-white/35">
|
||||
Puget Sound to the ridges · Many neighborhoods · One gentle invitation to apply
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
808
src/pages/ApplyPage.tsx
Normal file
808
src/pages/ApplyPage.tsx
Normal file
@@ -0,0 +1,808 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { houses, getHouseBySlug } from '../data/houses'
|
||||
import type { House } from '../types'
|
||||
import type {
|
||||
ApplicationPayloadV1,
|
||||
BedroomChoice,
|
||||
BudgetChoice,
|
||||
PaymentPath,
|
||||
} from '../types/rentalApplication'
|
||||
import { createEmptyApplicationPayload } from '../types/rentalApplication'
|
||||
import { AnimatedButton, ctaMistClass } from '../components/AnimatedButton'
|
||||
import { CHECKOUT_STORAGE_KEY } from '../constants/checkout'
|
||||
import {
|
||||
DeclarationsStep,
|
||||
EmploymentStep,
|
||||
HouseholdStep,
|
||||
IdentityStep,
|
||||
OfficialApplicationBanner,
|
||||
ResidenceStep,
|
||||
ReviewStep,
|
||||
} from './apply/RentalApplicationSteps'
|
||||
|
||||
const TOTAL_STEPS = 10
|
||||
|
||||
const STEP_LABELS = [
|
||||
'Bedrooms',
|
||||
'Budget',
|
||||
'Home',
|
||||
'Next step',
|
||||
'Identification',
|
||||
'Residence',
|
||||
'Employment',
|
||||
'Household',
|
||||
'Declarations',
|
||||
'Review & pay',
|
||||
]
|
||||
|
||||
function rentToTier(rent: number): BudgetChoice {
|
||||
if (rent < 4000) return 'under4'
|
||||
if (rent < 5500) return '4to55'
|
||||
if (rent < 7000) return '55to7'
|
||||
return 'over7'
|
||||
}
|
||||
|
||||
function bedsToChoice(beds: number): BedroomChoice {
|
||||
if (beds >= 3) return '3plus'
|
||||
if (beds === 2) return '2'
|
||||
return '1'
|
||||
}
|
||||
|
||||
function matchesBedrooms(h: House, bed: BedroomChoice): boolean {
|
||||
switch (bed) {
|
||||
case 'studio':
|
||||
return h.beds === 1
|
||||
case '1':
|
||||
return h.beds === 1
|
||||
case '2':
|
||||
return h.beds === 2
|
||||
case '3plus':
|
||||
return h.beds >= 3
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function matchesBudget(h: House, b: BudgetChoice): boolean {
|
||||
const r = h.rent
|
||||
switch (b) {
|
||||
case 'any':
|
||||
return true
|
||||
case 'under4':
|
||||
return r < 4000
|
||||
case '4to55':
|
||||
return r >= 4000 && r < 5500
|
||||
case '55to7':
|
||||
return r >= 5500 && r < 7000
|
||||
case 'over7':
|
||||
return r >= 7000
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
function ageFromDob(dob: string): number | null {
|
||||
const t = Date.parse(`${dob}T12:00:00`)
|
||||
if (Number.isNaN(t)) return null
|
||||
const diff = Date.now() - t
|
||||
return diff / (365.25 * 24 * 3600 * 1000)
|
||||
}
|
||||
|
||||
function buildStripeNotes(
|
||||
ref: string,
|
||||
payload: ApplicationPayloadV1,
|
||||
selectedHouse: House | undefined,
|
||||
bedroom: BedroomChoice | null,
|
||||
budget: BudgetChoice | null,
|
||||
relaxed: boolean,
|
||||
): string {
|
||||
const paymentPath = payload.prefs.paymentPath ?? 'pending'
|
||||
const lines = [
|
||||
`FILE ${ref}`,
|
||||
selectedHouse ? `PROP ${selectedHouse.name}` : 'PROP TBD',
|
||||
`STEP ${paymentPath}`,
|
||||
`FILTERS ${bedroom ?? ''}/${budget ?? ''}${relaxed ? '/relaxed' : ''}`,
|
||||
]
|
||||
return lines.join(' · ').slice(0, 500)
|
||||
}
|
||||
|
||||
export function ApplyPage() {
|
||||
const navigate = useNavigate()
|
||||
const [params] = useSearchParams()
|
||||
const initialSlug = params.get('house') || ''
|
||||
const houseFromUrl = initialSlug ? getHouseBySlug(initialSlug) : undefined
|
||||
|
||||
const [step, setStep] = useState(() => (houseFromUrl ? 3 : 1))
|
||||
const [bedroom, setBedroom] = useState<BedroomChoice | null>(
|
||||
houseFromUrl ? bedsToChoice(houseFromUrl.beds) : null,
|
||||
)
|
||||
const [budget, setBudget] = useState<BudgetChoice | null>(
|
||||
houseFromUrl ? rentToTier(houseFromUrl.rent) : null,
|
||||
)
|
||||
const [selectedSlug, setSelectedSlug] = useState(initialSlug)
|
||||
const [path, setPath] = useState<PaymentPath>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const pathRef = useRef<PaymentPath>(null)
|
||||
const payingRef = useRef(false)
|
||||
|
||||
const [payload, setPayload] = useState<ApplicationPayloadV1>(() => createEmptyApplicationPayload())
|
||||
const [applicationRef, setApplicationRef] = useState<string | null>(null)
|
||||
const [applicationSecret, setApplicationSecret] = useState<string | null>(null)
|
||||
const [applicationRefLoading, setApplicationRefLoading] = useState(false)
|
||||
const [applicationRefError, setApplicationRefError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
pathRef.current = path
|
||||
}, [path])
|
||||
|
||||
const payloadForServer = useMemo(
|
||||
() => ({
|
||||
...payload,
|
||||
prefs: {
|
||||
bedroom,
|
||||
budget,
|
||||
selectedSlug,
|
||||
paymentPath: path,
|
||||
},
|
||||
}),
|
||||
[payload, bedroom, budget, selectedSlug, path],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (step < 5 || applicationRef) return
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
setApplicationRefLoading(true)
|
||||
setApplicationRefError(null)
|
||||
try {
|
||||
const res = await fetch('/api/applications/init', { method: 'POST' })
|
||||
const data = (await res.json()) as {
|
||||
applicationRef?: string
|
||||
applicationSecret?: string
|
||||
error?: string
|
||||
}
|
||||
if (!res.ok) throw new Error(data.error || `Could not issue file number (${res.status}).`)
|
||||
if (!data.applicationRef) throw new Error('Server did not return an application reference.')
|
||||
if (!data.applicationSecret) throw new Error('Server did not return application credentials.')
|
||||
if (!cancelled) {
|
||||
setApplicationRef(data.applicationRef)
|
||||
setApplicationSecret(data.applicationSecret)
|
||||
sessionStorage.setItem('sv_application_ref', data.applicationRef)
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setApplicationRefError(e instanceof Error ? e.message : 'Could not reach the server.')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setApplicationRefLoading(false)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [step, applicationRef])
|
||||
|
||||
function selectPayment(p: 'deposit' | 'credit') {
|
||||
pathRef.current = p
|
||||
setPath(p)
|
||||
}
|
||||
|
||||
const selectedHouse = useMemo(
|
||||
() => (selectedSlug ? getHouseBySlug(selectedSlug) : undefined),
|
||||
[selectedSlug],
|
||||
)
|
||||
|
||||
const filteredHomes = useMemo(() => {
|
||||
if (!bedroom || !budget) return houses
|
||||
const matched = houses.filter(
|
||||
(h) => matchesBedrooms(h, bedroom) && matchesBudget(h, budget),
|
||||
)
|
||||
if (matched.length > 0) return matched
|
||||
const relaxed = houses.filter((h) => matchesBedrooms(h, bedroom))
|
||||
return relaxed.length > 0 ? relaxed : houses
|
||||
}, [bedroom, budget])
|
||||
|
||||
const showRelaxedNote = useMemo(() => {
|
||||
if (!bedroom || !budget) return false
|
||||
const strict = houses.filter(
|
||||
(h) => matchesBedrooms(h, bedroom) && matchesBudget(h, budget),
|
||||
)
|
||||
return strict.length === 0
|
||||
}, [bedroom, budget])
|
||||
|
||||
async function persistToServer(status?: 'draft' | 'submitted') {
|
||||
if (!applicationRef || !applicationSecret) return false
|
||||
const res = await fetch(`/api/applications/${encodeURIComponent(applicationRef)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Application-Secret': applicationSecret,
|
||||
},
|
||||
body: JSON.stringify({ payload: payloadForServer, ...(status ? { status } : {}) }),
|
||||
})
|
||||
return res.ok
|
||||
}
|
||||
|
||||
function validateForStep(s: number): string | null {
|
||||
if (s === 5) {
|
||||
const id = payload.identity
|
||||
if (!id.legalFirstName.trim() || !id.legalLastName.trim()) {
|
||||
return 'Enter your legal first and last name as shown on your ID.'
|
||||
}
|
||||
if (!id.dob) return 'Date of birth is required.'
|
||||
const age = ageFromDob(id.dob)
|
||||
if (age === null || age < 18) return 'You must be at least 18 years of age to apply.'
|
||||
if (id.ssn.length !== 9) return 'Enter a valid 9-digit Social Security number.'
|
||||
if (!id.idType) return 'Select a primary ID type.'
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(id.email.trim())) {
|
||||
return 'Enter a valid email address.'
|
||||
}
|
||||
if (!id.primaryPhone.trim()) return 'Mobile phone is required.'
|
||||
return null
|
||||
}
|
||||
if (s === 6) {
|
||||
const a = payload.currentAddress
|
||||
if (!a.street.trim() || !a.city.trim() || !a.state.trim() || !a.zip.trim()) {
|
||||
return 'Complete your current street, city, state, and ZIP.'
|
||||
}
|
||||
if (!a.moveInDate) return 'Current move-in date is required.'
|
||||
if (!a.rentOrOwn) return 'Select whether you rent, own, or other housing type.'
|
||||
if (!payload.addressTenureYears.trim()) {
|
||||
return 'Approximate years at your current address is required.'
|
||||
}
|
||||
const yrs = Number(payload.addressTenureYears)
|
||||
if (!Number.isFinite(yrs) || yrs < 0) {
|
||||
return 'Enter a valid number of years (decimals allowed, e.g. 1.5).'
|
||||
}
|
||||
const needPrior = payload.hasPriorAddress || yrs < 2
|
||||
if (needPrior) {
|
||||
const p = payload.priorAddress
|
||||
if (!p.street.trim() || !p.city.trim() || !p.state.trim() || !p.zip.trim()) {
|
||||
return 'Provide your prior address in full (required for less than two years at current).'
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (s === 7) {
|
||||
const e = payload.employment
|
||||
if (!e.status) return 'Select your employment status.'
|
||||
if (['employed', 'self', 'contract'].includes(e.status)) {
|
||||
if (!e.employerName.trim()) return 'Employer or business name is required.'
|
||||
if (!e.startDate) return 'Employment start date is required.'
|
||||
if (!e.monthlyGrossIncome.trim()) return 'Monthly gross income is required.'
|
||||
}
|
||||
if (payload.hasPriorEmployment) {
|
||||
const pe = payload.priorEmployment
|
||||
if (!pe.employerName.trim() || !pe.endDate) {
|
||||
return 'Complete prior employer name and end date.'
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (s === 8) {
|
||||
const em = payload.emergency
|
||||
if (!em.name.trim() || !em.relationship.trim() || !em.phone.trim()) {
|
||||
return 'Emergency contact name, relationship, and phone are required.'
|
||||
}
|
||||
const adults = Number(payload.household.adultsCount)
|
||||
if (!Number.isFinite(adults) || adults < 1) {
|
||||
return 'Adult count must be at least 1 (including you).'
|
||||
}
|
||||
if (!payload.household.pets.trim()) {
|
||||
return 'List pets or enter “None”.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (s === 9) {
|
||||
const d = payload.declarations
|
||||
if (
|
||||
d.bankrupt7y === null ||
|
||||
d.eviction3y === null ||
|
||||
d.felony7y === null ||
|
||||
d.suedForUnpaidRent3y === null
|
||||
) {
|
||||
return 'Answer each declaration question with Yes or No.'
|
||||
}
|
||||
const c = payload.consents
|
||||
if (!c.consumerReport || !c.fcraNotice || !c.informationAccurate) {
|
||||
return 'All three authorizations must be checked to continue.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function goNext() {
|
||||
setError(null)
|
||||
if (step === 1 && !bedroom) {
|
||||
setError('Select a bedroom count to continue.')
|
||||
return
|
||||
}
|
||||
if (step === 2 && !budget) {
|
||||
setError('Select a budget range (or choose “Show me everything”).')
|
||||
return
|
||||
}
|
||||
if (step === 4 && !pathRef.current) {
|
||||
setError('Choose a deposit or credit screening to continue.')
|
||||
return
|
||||
}
|
||||
if (step === 5) {
|
||||
if (applicationRefLoading || !applicationRef) {
|
||||
setError('Waiting for your official file number — try again in a moment.')
|
||||
return
|
||||
}
|
||||
}
|
||||
if (step >= 5 && step <= 9) {
|
||||
const v = validateForStep(step)
|
||||
if (v) {
|
||||
setError(v)
|
||||
return
|
||||
}
|
||||
const saved = await persistToServer('draft')
|
||||
if (!saved) {
|
||||
setError('Could not save your application to the server. Check your connection and try again.')
|
||||
return
|
||||
}
|
||||
}
|
||||
setStep((x) => Math.min(x + 1, TOTAL_STEPS))
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
setError(null)
|
||||
setStep((x) => Math.max(x - 1, 1))
|
||||
}
|
||||
|
||||
async function pay() {
|
||||
if (payingRef.current || loading) return
|
||||
setError(null)
|
||||
|
||||
const paymentType = pathRef.current ?? path
|
||||
if (!paymentType) {
|
||||
setError('Choose deposit or credit screening before payment.')
|
||||
return
|
||||
}
|
||||
if (!applicationRef) {
|
||||
setError('Missing application file number. Go back and wait for it to load.')
|
||||
return
|
||||
}
|
||||
if (!applicationSecret) {
|
||||
setError('Missing application credentials. Refresh and restart the application.')
|
||||
return
|
||||
}
|
||||
|
||||
const v = validateForStep(9)
|
||||
if (v) {
|
||||
setError(v)
|
||||
return
|
||||
}
|
||||
const id = payload.identity
|
||||
if (!id.email.trim()) {
|
||||
setError('Email is required for checkout.')
|
||||
return
|
||||
}
|
||||
|
||||
payingRef.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const saved = await persistToServer('submitted')
|
||||
if (!saved) {
|
||||
setError('Could not finalize your application on the server. Please try again.')
|
||||
return
|
||||
}
|
||||
|
||||
const fullName = [id.legalFirstName, id.legalMiddleName, id.legalLastName]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
const notes = buildStripeNotes(
|
||||
applicationRef,
|
||||
payloadForServer,
|
||||
selectedHouse,
|
||||
bedroom,
|
||||
budget,
|
||||
showRelaxedNote,
|
||||
)
|
||||
|
||||
const checkoutPayload = {
|
||||
applicationRef,
|
||||
applicationSecret,
|
||||
paymentType,
|
||||
houseId: selectedHouse?.id ?? '',
|
||||
houseName: selectedHouse?.name ?? '',
|
||||
houseArea: selectedHouse?.area ?? '',
|
||||
customerEmail: id.email.trim(),
|
||||
fullName,
|
||||
phone: id.primaryPhone.trim(),
|
||||
notes,
|
||||
}
|
||||
sessionStorage.setItem('sv_application_ref', applicationRef)
|
||||
sessionStorage.setItem(CHECKOUT_STORAGE_KEY, JSON.stringify(checkoutPayload))
|
||||
navigate('/apply/checkout')
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Something went wrong.'
|
||||
if (msg === 'Failed to fetch' || msg.includes('NetworkError')) {
|
||||
setError(
|
||||
'Cannot reach the payment service. Ensure the API is running and /api routes are reachable.',
|
||||
)
|
||||
} else {
|
||||
setError(msg)
|
||||
}
|
||||
} finally {
|
||||
payingRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const progress = (step / TOTAL_STEPS) * 100
|
||||
const wideForm = step >= 5
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-28 pt-8 sm:px-6">
|
||||
<div className={wideForm ? 'mx-auto max-w-3xl' : 'mx-auto max-w-2xl'}>
|
||||
<div className="mb-10 text-center">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.4em] text-amber-200/85">
|
||||
{step >= 5 ? 'Official application' : 'Your renter profile'}
|
||||
</p>
|
||||
<h1 className="font-display mt-3 text-balance text-3xl font-semibold text-white sm:text-4xl">
|
||||
{step >= 5
|
||||
? 'Rental application & screening packet'
|
||||
: 'Let’s find the right fit — then secure your next step.'}
|
||||
</h1>
|
||||
<p className="mx-auto mt-3 max-w-2xl text-pretty text-sm text-white/60">
|
||||
{step >= 5
|
||||
? 'This is a formal housing application suitable for tenancy decisions and consumer-report–based screening. Data you submit is stored on the Sacred Villas server for processing — protect your account and log out on shared devices.'
|
||||
: 'A few quick choices, like a guided search. Then complete the official application, choose deposit or credit screening, and pay securely with Stripe.'}
|
||||
</p>
|
||||
|
||||
<div className="mx-auto mt-8 max-w-md">
|
||||
<div className="flex justify-between text-xs font-medium text-white/45">
|
||||
<span>
|
||||
Step {step} of {TOTAL_STEPS}
|
||||
</span>
|
||||
<span className="text-white/55">{STEP_LABELS[step - 1]}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-white/10">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-gradient-to-r from-amber-400/90 to-amber-200/80"
|
||||
initial={false}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ type: 'spring', stiffness: 120, damping: 20 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel relative min-h-[320px] overflow-hidden rounded-3xl px-5 py-8 sm:px-10 sm:py-10">
|
||||
<AnimatePresence mode="wait">
|
||||
{step === 1 && (
|
||||
<StepPanel key="s1">
|
||||
<h2 className="font-display text-center text-2xl font-semibold text-white sm:text-3xl">
|
||||
How many bedrooms do you need?
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-center text-sm text-white/55">
|
||||
Tap an option — we’ll tailor matches in the next steps.
|
||||
</p>
|
||||
<div className="mt-8 grid grid-cols-2 gap-3 sm:grid-cols-4 sm:gap-4">
|
||||
{(
|
||||
[
|
||||
{ id: 'studio' as const, short: 'S', label: 'Studio', sub: 'Efficiency / flex' },
|
||||
{ id: '1' as const, short: '1', label: '1 Bed', sub: 'One bedroom' },
|
||||
{ id: '2' as const, short: '2', label: '2 Beds', sub: 'Two bedrooms' },
|
||||
{ id: '3plus' as const, short: '3+', label: '3+ Beds', sub: 'Three or more' },
|
||||
] as const
|
||||
).map((opt) => (
|
||||
<BedroomCard
|
||||
key={opt.id}
|
||||
selected={bedroom === opt.id}
|
||||
onSelect={() => setBedroom(opt.id)}
|
||||
short={opt.short}
|
||||
label={opt.label}
|
||||
sub={opt.sub}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</StepPanel>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<StepPanel key="s2">
|
||||
<h2 className="font-display text-center text-2xl font-semibold text-white sm:text-3xl">
|
||||
What monthly rent range feels right?
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-center text-sm text-white/55">
|
||||
We’ll prioritize homes in range; you can still browse the full collection later.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-col gap-3">
|
||||
{(
|
||||
[
|
||||
{ id: 'under4' as const, title: 'Under $4,000', hint: 'Entry to mid band' },
|
||||
{ id: '4to55' as const, title: '$4,000 – $5,499', hint: 'Popular sweet spot' },
|
||||
{ id: '55to7' as const, title: '$5,500 – $6,999', hint: 'Spacious plans' },
|
||||
{ id: 'over7' as const, title: '$7,000+', hint: 'Estate-scale & views' },
|
||||
{ id: 'any' as const, title: 'Show me everything', hint: 'No budget filter' },
|
||||
] as const
|
||||
).map((opt) => (
|
||||
<BudgetRow
|
||||
key={opt.id}
|
||||
selected={budget === opt.id}
|
||||
onSelect={() => setBudget(opt.id)}
|
||||
title={opt.title}
|
||||
hint={opt.hint}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</StepPanel>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<StepPanel key="s3">
|
||||
<h2 className="font-display text-center text-2xl font-semibold text-white sm:text-3xl">
|
||||
Which home speaks to you?
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-center text-sm text-white/55">
|
||||
{showRelaxedNote
|
||||
? 'No exact match for bedroom + budget together — showing the closest bedroom matches. You can still pick any home.'
|
||||
: 'Based on your bedroom and budget choices, here are strong matches.'}
|
||||
</p>
|
||||
<div className="mt-6 max-h-[min(52vh,420px)] space-y-2 overflow-y-auto pr-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedSlug('')}
|
||||
className={`flex w-full items-center justify-between rounded-2xl border px-4 py-4 text-left transition ${
|
||||
selectedSlug === ''
|
||||
? 'border-amber-300/60 bg-amber-400/10'
|
||||
: 'border-white/12 bg-white/[0.03] hover:border-white/25'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-white">I’m still deciding</p>
|
||||
<p className="text-sm text-white/50">Apply generally — we’ll pair you with a home</p>
|
||||
</div>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-white/40">
|
||||
Optional
|
||||
</span>
|
||||
</button>
|
||||
{filteredHomes.map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSlug(h.slug)}
|
||||
className={`flex w-full items-center gap-4 rounded-2xl border px-3 py-3 text-left transition sm:px-4 ${
|
||||
selectedSlug === h.slug
|
||||
? 'border-amber-300/60 bg-amber-400/10'
|
||||
: 'border-white/12 bg-white/[0.03] hover:border-white/25'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={h.images[0]}
|
||||
alt=""
|
||||
className="h-16 w-24 shrink-0 rounded-xl object-cover sm:h-[72px] sm:w-28"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-white">{h.name}</p>
|
||||
<p className="line-clamp-2 text-[0.65rem] font-medium uppercase leading-snug tracking-[0.06em] text-amber-200/70">
|
||||
{h.area}
|
||||
</p>
|
||||
<p className="mt-0.5 text-sm text-white/50">
|
||||
{h.neighborhood} · {h.beds} bd · ${h.rent.toLocaleString()}/mo
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</StepPanel>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<StepPanel key="s4">
|
||||
<h2 className="font-display text-center text-2xl font-semibold text-white sm:text-3xl">
|
||||
How would you like to move forward?
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-center text-sm text-white/55">
|
||||
Both end in secure Stripe checkout — pick what fits your timeline.
|
||||
</p>
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-2">
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={() => selectPayment('deposit')}
|
||||
whileHover={{ y: -3 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className={`rounded-2xl border p-6 text-left transition ${
|
||||
path === 'deposit'
|
||||
? 'border-amber-300/70 bg-amber-300/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]'
|
||||
: 'border-white/12 bg-white/[0.03] hover:border-white/25'
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.25em] text-amber-200/90">
|
||||
Priority
|
||||
</p>
|
||||
<p className="font-display mt-3 text-3xl font-semibold text-white">$500</p>
|
||||
<p className="mt-1 text-sm text-white/65">Holding deposit</p>
|
||||
</motion.button>
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={() => selectPayment('credit')}
|
||||
whileHover={{ y: -3 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className={`rounded-2xl border p-6 text-left transition ${
|
||||
path === 'credit'
|
||||
? 'border-sky-300/60 bg-sky-400/10 shadow-[0_0_0_1px_rgba(125,211,252,0.25)]'
|
||||
: 'border-white/12 bg-white/[0.03] hover:border-white/25'
|
||||
}`}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.25em] text-sky-200/90">
|
||||
Screening
|
||||
</p>
|
||||
<p className="font-display mt-3 text-3xl font-semibold text-white">$75</p>
|
||||
<p className="mt-1 text-sm text-white/65">Credit & screening fee</p>
|
||||
</motion.button>
|
||||
</div>
|
||||
</StepPanel>
|
||||
)}
|
||||
|
||||
{step >= 5 && step <= 10 && (
|
||||
<StepPanel key={`app-${step}`}>
|
||||
<OfficialApplicationBanner
|
||||
applicationRef={applicationRef}
|
||||
loading={applicationRefLoading}
|
||||
error={applicationRefError}
|
||||
/>
|
||||
{step === 5 && <IdentityStep payload={payload} setPayload={setPayload} />}
|
||||
{step === 6 && <ResidenceStep payload={payload} setPayload={setPayload} />}
|
||||
{step === 7 && <EmploymentStep payload={payload} setPayload={setPayload} />}
|
||||
{step === 8 && <HouseholdStep payload={payload} setPayload={setPayload} />}
|
||||
{step === 9 && <DeclarationsStep payload={payload} setPayload={setPayload} />}
|
||||
{step === 10 && (
|
||||
<>
|
||||
<ReviewStep
|
||||
payload={payloadForServer}
|
||||
applicationRef={applicationRef}
|
||||
selectedHouse={selectedHouse}
|
||||
showRelaxedNote={showRelaxedNote}
|
||||
bedroom={bedroom}
|
||||
budget={budget}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-6 rounded-xl border border-red-400/30 bg-red-500/10 px-4 py-3 text-sm text-red-100/95">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<AnimatedButton onClick={pay} disabled={loading || applicationRefLoading}>
|
||||
{loading ? 'Opening secure checkout…' : 'Continue to secure payment'}
|
||||
</AnimatedButton>
|
||||
<p className="text-xs text-white/45">
|
||||
Stripe processes card payments. Your application file is saved on Sacred Villas
|
||||
servers before checkout.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{step >= 5 && step < 10 && error && (
|
||||
<p className="mt-6 rounded-xl border border-red-400/30 bg-red-500/10 px-4 py-3 text-sm text-red-100/95">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</StepPanel>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
{step > 1 && (
|
||||
<button type="button" onClick={goBack} className={ctaMistClass}>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{step < TOTAL_STEPS && step !== 10 && (
|
||||
<AnimatedButton type="button" onClick={goNext}>
|
||||
Next
|
||||
</AnimatedButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && step < 5 && (
|
||||
<p className="mt-4 text-center text-sm text-red-200/90">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepPanel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
role="region"
|
||||
initial={{ opacity: 0, x: 16 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -16 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function BedroomCard({
|
||||
selected,
|
||||
onSelect,
|
||||
short,
|
||||
label,
|
||||
sub,
|
||||
}: {
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
short: string
|
||||
label: string
|
||||
sub: string
|
||||
}) {
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
whileHover={{ y: -4 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className={`flex min-h-[120px] flex-col items-center justify-center rounded-2xl border-2 px-2 py-4 text-center transition sm:min-h-[140px] ${
|
||||
selected
|
||||
? 'border-amber-400/80 bg-amber-400/15 shadow-[0_0_0_1px_rgba(251,191,36,0.35)]'
|
||||
: 'border-white/15 bg-white/[0.04] hover:border-white/35'
|
||||
}`}
|
||||
>
|
||||
<span className="font-display text-4xl font-semibold tabular-nums text-white sm:text-5xl">
|
||||
{short}
|
||||
</span>
|
||||
<span className="mt-2 text-xs font-semibold uppercase tracking-[0.2em] text-white/55">
|
||||
{label}
|
||||
</span>
|
||||
<span className="mt-1 hidden text-[11px] text-white/40 sm:block">{sub}</span>
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
|
||||
function BudgetRow({
|
||||
selected,
|
||||
onSelect,
|
||||
title,
|
||||
hint,
|
||||
}: {
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
title: string
|
||||
hint: string
|
||||
}) {
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
whileHover={{ x: 4 }}
|
||||
whileTap={{ scale: 0.995 }}
|
||||
className={`flex w-full items-center justify-between rounded-2xl border px-4 py-4 text-left transition ${
|
||||
selected
|
||||
? 'border-emerald-400/50 bg-emerald-500/10'
|
||||
: 'border-white/12 bg-white/[0.03] hover:border-white/25'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-white">{title}</p>
|
||||
<p className="text-sm text-white/45">{hint}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`h-5 w-5 shrink-0 rounded-full border-2 ${
|
||||
selected ? 'border-emerald-300 bg-emerald-400/40' : 'border-white/25'
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
173
src/pages/CheckoutEmbeddedPage.tsx
Normal file
173
src/pages/CheckoutEmbeddedPage.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { loadStripe } from '@stripe/stripe-js'
|
||||
import { EmbeddedCheckout, EmbeddedCheckoutProvider } from '@stripe/react-stripe-js'
|
||||
import { motion } from 'framer-motion'
|
||||
import { CHECKOUT_STORAGE_KEY } from '../constants/checkout'
|
||||
|
||||
export function CheckoutEmbeddedPage() {
|
||||
const navigate = useNavigate()
|
||||
const pk = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY
|
||||
const stripePromise = useMemo(
|
||||
() => (pk ? loadStripe(pk) : null),
|
||||
[pk],
|
||||
)
|
||||
|
||||
const [clientSecret, setClientSecret] = useState<string | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const raw = sessionStorage.getItem(CHECKOUT_STORAGE_KEY)
|
||||
if (!raw) {
|
||||
setLoadError('No checkout session found. Start from the application.')
|
||||
return
|
||||
}
|
||||
|
||||
let payload: object
|
||||
try {
|
||||
payload = JSON.parse(raw) as object
|
||||
} catch {
|
||||
setLoadError('Invalid checkout data. Please try again from the application.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/create-checkout-session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const data = (await res.json()) as { clientSecret?: string; error?: string }
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `Checkout failed (${res.status}).`)
|
||||
}
|
||||
if (!data.clientSecret) {
|
||||
throw new Error('No client secret returned from the server.')
|
||||
}
|
||||
if (!cancelled) setClientSecret(data.clientSecret)
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setLoadError(e instanceof Error ? e.message : 'Could not start checkout.')
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!pk) {
|
||||
return (
|
||||
<div className="mx-auto max-w-lg px-4 py-20 text-center">
|
||||
<p className="text-sm font-medium uppercase tracking-[0.25em] text-amber-200/80">
|
||||
Configuration
|
||||
</p>
|
||||
<h1 className="font-display mt-3 text-2xl text-white">Publishable key missing</h1>
|
||||
<p className="mt-3 text-pretty text-white/65">
|
||||
Add <code className="rounded bg-white/10 px-1.5 py-0.5 text-sm">VITE_STRIPE_PUBLISHABLE_KEY</code>{' '}
|
||||
to your <code className="rounded bg-white/10 px-1.5 py-0.5 text-sm">.env</code>, restart the dev
|
||||
server, and try again.
|
||||
</p>
|
||||
<Link
|
||||
to="/apply"
|
||||
className="mt-8 inline-block rounded-full border border-white/20 px-5 py-2.5 text-sm font-medium text-white/90 transition hover:bg-white/10"
|
||||
>
|
||||
Back to application
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="mx-auto max-w-lg px-4 py-20 text-center">
|
||||
<p className="text-sm font-medium uppercase tracking-[0.25em] text-red-200/85">Checkout</p>
|
||||
<h1 className="font-display mt-3 text-2xl text-white">Couldn’t start payment</h1>
|
||||
<p className="mt-3 text-pretty text-white/65">{loadError}</p>
|
||||
<Link
|
||||
to="/apply"
|
||||
className="mt-8 inline-block rounded-full border border-white/20 px-5 py-2.5 text-sm font-medium text-white/90 transition hover:bg-white/10"
|
||||
>
|
||||
Back to application
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!clientSecret || !stripePromise) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
|
||||
<div
|
||||
className="h-10 w-10 animate-spin rounded-full border-2 border-amber-200/30 border-t-amber-200"
|
||||
aria-hidden
|
||||
/>
|
||||
<p className="text-sm text-white/55">Preparing secure checkout…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-24 pt-8 sm:px-6">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.45 }}
|
||||
className="text-center"
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.4em] text-amber-200/85">
|
||||
Sacred Villas · Secure checkout
|
||||
</p>
|
||||
<h1 className="font-display mt-4 text-balance text-3xl font-semibold text-white sm:text-4xl">
|
||||
Complete your payment
|
||||
</h1>
|
||||
<p className="mx-auto mt-3 max-w-xl text-pretty text-sm text-white/55">
|
||||
Embedded Stripe Checkout — your card details stay with Stripe. You can return to your
|
||||
application anytime before paying.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.08 }}
|
||||
className="relative mt-10 overflow-hidden rounded-3xl border border-white/15 bg-[#0a1220]/40 p-4 shadow-[0_32px_120px_rgba(0,0,0,0.45)] backdrop-blur-xl sm:p-8"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.35]"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(ellipse 80% 50% at 50% 0%, rgba(251, 191, 36, 0.12), transparent 55%)',
|
||||
}}
|
||||
/>
|
||||
<div className="relative min-h-[420px]">
|
||||
<EmbeddedCheckoutProvider
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret,
|
||||
onComplete: () => {
|
||||
sessionStorage.removeItem(CHECKOUT_STORAGE_KEY)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<EmbeddedCheckout id="embedded-checkout" className="min-h-[380px]" />
|
||||
</EmbeddedCheckoutProvider>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<p className="mt-8 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/apply')}
|
||||
className="text-sm text-white/45 underline-offset-4 transition hover:text-white/75 hover:underline"
|
||||
>
|
||||
← Back to application
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
179
src/pages/CheckoutReturnPage.tsx
Normal file
179
src/pages/CheckoutReturnPage.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { MotionRouterLink } from '../components/MotionLink'
|
||||
import { ctaGoldClass, ctaMistClass } from '../components/AnimatedButton'
|
||||
|
||||
type SessionPayload = {
|
||||
status?: string
|
||||
payment_status?: string
|
||||
customer_email?: string | null
|
||||
application_ref?: string | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function CheckoutReturnPage() {
|
||||
const [params] = useSearchParams()
|
||||
const sessionId = params.get('session_id') || ''
|
||||
const [data, setData] = useState<SessionPayload | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [syncedRef, setSyncedRef] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
if (!sessionId) {
|
||||
setData({ error: 'Missing session. Return to the application and try checkout again.' })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/checkout-session-status?session_id=${encodeURIComponent(sessionId)}`,
|
||||
)
|
||||
const json = (await res.json()) as SessionPayload
|
||||
if (!res.ok) {
|
||||
throw new Error(json.error || `Request failed (${res.status}).`)
|
||||
}
|
||||
if (!cancelled) setData(json)
|
||||
|
||||
const complete = json.status === 'complete' && json.payment_status === 'paid'
|
||||
if (complete && !cancelled) {
|
||||
try {
|
||||
const sync = await fetch('/api/applications/sync-payment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId }),
|
||||
})
|
||||
const body = (await sync.json()) as { ok?: boolean; applicationRef?: string }
|
||||
if (body.ok && body.applicationRef && !cancelled) {
|
||||
setSyncedRef(body.applicationRef)
|
||||
} else if (json.application_ref && !cancelled) {
|
||||
setSyncedRef(json.application_ref)
|
||||
}
|
||||
} catch {
|
||||
if (json.application_ref && !cancelled) setSyncedRef(json.application_ref)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setData({
|
||||
error: e instanceof Error ? e.message : 'Could not verify your session.',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] flex-col items-center justify-center gap-3 px-4">
|
||||
<div
|
||||
className="h-9 w-9 animate-spin rounded-full border-2 border-emerald-200/30 border-t-emerald-200"
|
||||
aria-hidden
|
||||
/>
|
||||
<p className="text-sm text-white/55">Confirming your session…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (data?.error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl px-4 py-20 text-center sm:px-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.35em] text-amber-200/85">
|
||||
Checkout
|
||||
</p>
|
||||
<h1 className="font-display mt-4 text-3xl font-semibold text-white">Something went wrong</h1>
|
||||
<p className="mt-4 text-pretty text-white/70">{data.error}</p>
|
||||
<div className="mt-10 flex flex-wrap justify-center gap-4">
|
||||
<MotionRouterLink to="/apply" className={ctaGoldClass}>
|
||||
Return to application
|
||||
</MotionRouterLink>
|
||||
<MotionRouterLink to="/" className={ctaMistClass}>
|
||||
Home
|
||||
</MotionRouterLink>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const complete = data?.status === 'complete' && data?.payment_status === 'paid'
|
||||
const displayRef = syncedRef || data?.application_ref || sessionStorage.getItem('sv_application_ref')
|
||||
|
||||
if (complete) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl px-4 py-20 text-center sm:px-6">
|
||||
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.4em] text-emerald-200/90">
|
||||
Payment received
|
||||
</p>
|
||||
<h1 className="font-display mt-4 text-4xl font-semibold text-white">Thank you</h1>
|
||||
{displayRef && (
|
||||
<p className="mt-6 rounded-2xl border border-amber-400/25 bg-amber-400/10 px-5 py-4 text-left text-sm text-white/80">
|
||||
<span className="block text-[0.6rem] font-semibold uppercase tracking-[0.28em] text-white/45">
|
||||
Application file number
|
||||
</span>
|
||||
<span className="mt-2 block font-mono text-xl font-semibold text-amber-100">{displayRef}</span>
|
||||
<span className="mt-2 block text-xs text-white/50">
|
||||
Keep this reference with your Stripe receipt for screening and deposit reconciliation.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-4 text-pretty text-white/70">
|
||||
Your checkout completed successfully
|
||||
{data?.customer_email ? (
|
||||
<>
|
||||
{' '}
|
||||
— we'll follow up at <span className="text-white/90">{data.customer_email}</span>
|
||||
</>
|
||||
) : (
|
||||
<> — our team will follow up shortly with next steps.</>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-4 text-sm text-white/45">
|
||||
If you don't hear from us within one business day, reply to your Stripe receipt or
|
||||
the email we send next.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-wrap justify-center gap-4">
|
||||
<MotionRouterLink to="/" className={ctaGoldClass}>
|
||||
Return home
|
||||
</MotionRouterLink>
|
||||
<MotionRouterLink to="/apply" className={ctaMistClass}>
|
||||
Another application
|
||||
</MotionRouterLink>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl px-4 py-20 text-center sm:px-6">
|
||||
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.4em] text-amber-200/85">
|
||||
Checkout incomplete
|
||||
</p>
|
||||
<h1 className="font-display mt-4 text-3xl font-semibold text-white">Payment not finished</h1>
|
||||
<p className="mt-4 text-pretty text-white/70">
|
||||
This session is still open or wasn't completed. You can return to checkout and try
|
||||
again — nothing has been charged for a successful payment yet.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-wrap justify-center gap-4">
|
||||
<MotionRouterLink to="/apply" className={ctaGoldClass}>
|
||||
Return to application
|
||||
</MotionRouterLink>
|
||||
<MotionRouterLink to="/" className={ctaMistClass}>
|
||||
Home
|
||||
</MotionRouterLink>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
106
src/pages/HomePage.tsx
Normal file
106
src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { houses } from '../data/houses'
|
||||
import { HouseCard } from '../components/HouseCard'
|
||||
import { ctaGoldClass, ctaMistClass } from '../components/AnimatedButton'
|
||||
import { MotionRouterLink } from '../components/MotionLink'
|
||||
|
||||
export function HomePage() {
|
||||
return (
|
||||
<div>
|
||||
<section className="relative overflow-hidden px-4 pb-20 pt-14 sm:px-6 sm:pt-20">
|
||||
<div className="mx-auto max-w-4xl text-center">
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-xs font-semibold uppercase tracking-[0.45em] text-amber-200/80"
|
||||
>
|
||||
Seattle & surrounds · A thread of stillness in every neighborhood
|
||||
</motion.p>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.05 }}
|
||||
className="font-display mt-5 text-balance text-4xl font-semibold leading-[1.1] text-white sm:text-6xl"
|
||||
>
|
||||
Homes scattered like stars across the city — each one a quiet altar to daily life.
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.12 }}
|
||||
className="mx-auto mt-6 max-w-2xl text-pretty text-lg text-white/75"
|
||||
>
|
||||
Sacred Villas gathers lovingly tended residences from Ballard breezes to Eastlake ripples,
|
||||
from ridge-line views to hidden courtyards you would never find from the freeway alone.
|
||||
Wander the collection, let a place choose you, then seal your intention with a secure
|
||||
holding deposit or applicant screening — the practical magic handled for you.
|
||||
</motion.p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="mt-10 flex flex-wrap items-center justify-center gap-4"
|
||||
>
|
||||
<MotionRouterLink to="/apply" className={ctaGoldClass}>
|
||||
Begin your application
|
||||
</MotionRouterLink>
|
||||
<motion.a
|
||||
href="#collection"
|
||||
whileHover={{ scale: 1.02, y: -1 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ type: 'spring', stiffness: 420, damping: 24 }}
|
||||
className={ctaMistClass}
|
||||
>
|
||||
Browse the collection
|
||||
</motion.a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="collection" className="scroll-mt-24 px-4 pb-24 sm:px-6">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-12 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="font-display text-3xl font-semibold text-white sm:text-4xl">
|
||||
The constellation
|
||||
</h2>
|
||||
<p className="mt-2 max-w-xl text-pretty text-white/68">
|
||||
Every card names its corner of the city — Belltown & Downtown, South Lake Union
|
||||
(SLU), Columbia City, West Seattle, and neighborhoods in between. Drift through; area,
|
||||
beds, size, and rent unfurl below in a soft reveal.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-white/45">{houses.length} residences</p>
|
||||
</div>
|
||||
<div className="grid gap-8 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{houses.map((h, i) => (
|
||||
<HouseCard key={h.id} house={h} index={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="concierge"
|
||||
className="scroll-mt-24 border-t border-white/10 bg-gradient-to-b from-transparent to-black/25 px-4 py-24 sm:px-6"
|
||||
>
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<h2 className="font-display text-3xl font-semibold text-white sm:text-4xl">
|
||||
Guided by humans, grounded in care
|
||||
</h2>
|
||||
<p className="mt-4 text-pretty text-white/70">
|
||||
When a home hums your name, we open the door quietly: private tours, honest answers, and a
|
||||
gentle path through paperwork. The closing step is simple — secure checkout for your holding
|
||||
deposit or credit screening — so you can return to dreaming about where the light falls.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<MotionRouterLink to="/apply" className={ctaGoldClass}>
|
||||
Reserve your place in line
|
||||
</MotionRouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
src/pages/HousePage.tsx
Normal file
97
src/pages/HousePage.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { getHouseBySlug } from '../data/houses'
|
||||
import { ImageGallery } from '../components/ImageGallery'
|
||||
import { HighlightFade, HouseSpecFade } from '../components/HouseSpecFade'
|
||||
import { MotionRouterLink } from '../components/MotionLink'
|
||||
import { ctaGoldClass, ctaMistClass } from '../components/AnimatedButton'
|
||||
|
||||
export function HousePage() {
|
||||
const { slug } = useParams()
|
||||
const house = slug ? getHouseBySlug(slug) : undefined
|
||||
|
||||
if (!house) {
|
||||
return (
|
||||
<div className="mx-auto max-w-lg px-4 py-24 text-center">
|
||||
<h1 className="font-display text-3xl text-white">Residence not found</h1>
|
||||
<p className="mt-3 text-white/65">This listing may have been updated.</p>
|
||||
<Link to="/" className="mt-8 inline-block text-amber-200/90 underline-offset-4 hover:underline">
|
||||
Return to the collection
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-24 pt-10 sm:px-6">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<motion.nav
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mb-8 text-sm text-white/50"
|
||||
>
|
||||
<Link to="/" className="hover:text-white/80">
|
||||
Collection
|
||||
</Link>
|
||||
<span className="mx-2 text-white/30">/</span>
|
||||
<span className="max-w-[min(100%,12rem)] truncate text-white/75" title={house.area}>
|
||||
{house.neighborhood}
|
||||
</span>
|
||||
</motion.nav>
|
||||
|
||||
<div className="grid gap-12 lg:grid-cols-[1.1fr_0.9fr] lg:items-start">
|
||||
<div>
|
||||
<ImageGallery images={house.images} title={house.name} />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase leading-relaxed tracking-[0.2em] text-amber-200/85">
|
||||
{house.area}
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs font-medium text-white/50">{house.neighborhood}</p>
|
||||
<h1 className="font-display mt-3 text-balance text-4xl font-semibold text-white sm:text-5xl">
|
||||
{house.name}
|
||||
</h1>
|
||||
<p className="mt-3 text-lg text-amber-100/85">{house.tagline}</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-panel rounded-2xl p-5">
|
||||
<HouseSpecFade house={house} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<MotionRouterLink
|
||||
to={`/apply?house=${encodeURIComponent(house.slug)}`}
|
||||
className={ctaGoldClass}
|
||||
>
|
||||
Apply for this home
|
||||
</MotionRouterLink>
|
||||
<MotionRouterLink
|
||||
to={{ pathname: '/', hash: '#collection' }}
|
||||
className={ctaMistClass}
|
||||
>
|
||||
Back to collection
|
||||
</MotionRouterLink>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.55, delay: 0.35, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="max-w-none text-base leading-relaxed text-white/78"
|
||||
>
|
||||
<p className="text-pretty">{house.description}</p>
|
||||
</motion.div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-display text-xl font-semibold text-white/95">
|
||||
Little enchantments
|
||||
</h2>
|
||||
<HighlightFade items={house.highlights} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1281
src/pages/apply/RentalApplicationSteps.tsx
Normal file
1281
src/pages/apply/RentalApplicationSteps.tsx
Normal file
File diff suppressed because it is too large
Load Diff
21
src/types.ts
Normal file
21
src/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type House = {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
/** Specific micro-hood (e.g. Belltown, Ballard). */
|
||||
neighborhood: string
|
||||
/** Broader Seattle district label for browsing (e.g. Belltown & Downtown, SLU). */
|
||||
area: string
|
||||
tagline: string
|
||||
description: string
|
||||
beds: number
|
||||
baths: number
|
||||
halfBaths?: number
|
||||
sqft: number
|
||||
rent: number
|
||||
availableLabel: string
|
||||
images: string[]
|
||||
highlights: string[]
|
||||
petPolicy: string
|
||||
parking: string
|
||||
}
|
||||
185
src/types/rentalApplication.ts
Normal file
185
src/types/rentalApplication.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
export type PaymentPath = 'deposit' | 'credit' | null
|
||||
export type BedroomChoice = 'studio' | '1' | '2' | '3plus'
|
||||
export type BudgetChoice = 'any' | 'under4' | '4to55' | '55to7' | 'over7'
|
||||
|
||||
export type ApplicationPayloadV1 = {
|
||||
version: 1
|
||||
prefs: {
|
||||
bedroom: BedroomChoice | null
|
||||
budget: BudgetChoice | null
|
||||
selectedSlug: string
|
||||
paymentPath: PaymentPath
|
||||
}
|
||||
identity: {
|
||||
legalFirstName: string
|
||||
legalMiddleName: string
|
||||
legalLastName: string
|
||||
formerNames: string
|
||||
dob: string
|
||||
ssn: string
|
||||
idType: string
|
||||
idState: string
|
||||
idNumber: string
|
||||
email: string
|
||||
primaryPhone: string
|
||||
alternatePhone: string
|
||||
}
|
||||
currentAddress: {
|
||||
street: string
|
||||
unit: string
|
||||
city: string
|
||||
state: string
|
||||
zip: string
|
||||
monthlyHousingPayment: string
|
||||
landlordOrLenderName: string
|
||||
landlordOrLenderPhone: string
|
||||
moveInDate: string
|
||||
rentOrOwn: 'rent' | 'own' | 'other' | ''
|
||||
}
|
||||
addressTenureYears: string
|
||||
hasPriorAddress: boolean
|
||||
priorAddress: {
|
||||
street: string
|
||||
unit: string
|
||||
city: string
|
||||
state: string
|
||||
zip: string
|
||||
moveOutDate: string
|
||||
}
|
||||
employment: {
|
||||
status: string
|
||||
employerName: string
|
||||
employerAddress: string
|
||||
occupation: string
|
||||
supervisorName: string
|
||||
supervisorPhone: string
|
||||
startDate: string
|
||||
monthlyGrossIncome: string
|
||||
}
|
||||
hasPriorEmployment: boolean
|
||||
priorEmployment: {
|
||||
employerName: string
|
||||
occupation: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
monthlyGrossIncome: string
|
||||
}
|
||||
otherIncomeDescription: string
|
||||
otherIncomeMonthly: string
|
||||
household: {
|
||||
adultsCount: string
|
||||
minorsCount: string
|
||||
otherOccupantsNames: string
|
||||
pets: string
|
||||
vehicles: string
|
||||
}
|
||||
emergency: {
|
||||
name: string
|
||||
relationship: string
|
||||
phone: string
|
||||
email: string
|
||||
}
|
||||
declarations: {
|
||||
bankrupt7y: boolean | null
|
||||
eviction3y: boolean | null
|
||||
felony7y: boolean | null
|
||||
suedForUnpaidRent3y: boolean | null
|
||||
}
|
||||
consents: {
|
||||
consumerReport: boolean
|
||||
fcraNotice: boolean
|
||||
informationAccurate: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyApplicationPayload(): ApplicationPayloadV1 {
|
||||
return {
|
||||
version: 1,
|
||||
prefs: {
|
||||
bedroom: null,
|
||||
budget: null,
|
||||
selectedSlug: '',
|
||||
paymentPath: null,
|
||||
},
|
||||
identity: {
|
||||
legalFirstName: '',
|
||||
legalMiddleName: '',
|
||||
legalLastName: '',
|
||||
formerNames: '',
|
||||
dob: '',
|
||||
ssn: '',
|
||||
idType: '',
|
||||
idState: '',
|
||||
idNumber: '',
|
||||
email: '',
|
||||
primaryPhone: '',
|
||||
alternatePhone: '',
|
||||
},
|
||||
currentAddress: {
|
||||
street: '',
|
||||
unit: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zip: '',
|
||||
monthlyHousingPayment: '',
|
||||
landlordOrLenderName: '',
|
||||
landlordOrLenderPhone: '',
|
||||
moveInDate: '',
|
||||
rentOrOwn: '',
|
||||
},
|
||||
addressTenureYears: '',
|
||||
hasPriorAddress: false,
|
||||
priorAddress: {
|
||||
street: '',
|
||||
unit: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zip: '',
|
||||
moveOutDate: '',
|
||||
},
|
||||
employment: {
|
||||
status: '',
|
||||
employerName: '',
|
||||
employerAddress: '',
|
||||
occupation: '',
|
||||
supervisorName: '',
|
||||
supervisorPhone: '',
|
||||
startDate: '',
|
||||
monthlyGrossIncome: '',
|
||||
},
|
||||
hasPriorEmployment: false,
|
||||
priorEmployment: {
|
||||
employerName: '',
|
||||
occupation: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
monthlyGrossIncome: '',
|
||||
},
|
||||
otherIncomeDescription: '',
|
||||
otherIncomeMonthly: '',
|
||||
household: {
|
||||
adultsCount: '1',
|
||||
minorsCount: '0',
|
||||
otherOccupantsNames: '',
|
||||
pets: '',
|
||||
vehicles: '',
|
||||
},
|
||||
emergency: {
|
||||
name: '',
|
||||
relationship: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
},
|
||||
declarations: {
|
||||
bankrupt7y: null,
|
||||
eviction3y: null,
|
||||
felony7y: null,
|
||||
suedForUnpaidRent3y: null,
|
||||
},
|
||||
consents: {
|
||||
consumerReport: false,
|
||||
fcraNotice: false,
|
||||
informationAccurate: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
9
src/vite-env.d.ts
vendored
Normal file
9
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_STRIPE_PUBLISHABLE_KEY?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
Reference in New Issue
Block a user