first commit

Made-with: Cursor
This commit is contained in:
Dr Jones
2026-04-25 06:56:23 +00:00
commit 697185c0a1
40 changed files with 10480 additions and 0 deletions

24
src/App.tsx Normal file
View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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}
/>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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 crows nest.',
description:
'Three breaths of vertical home — a stair that hums, bedrooms tucked in treetops, and a roof deck where Fremonts sky bruises pink over the water. Gallery walls wait for your story; the citys 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 Parks 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 lakes 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 someones 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 homes 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 neighborhoods 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 Seattles innovation district at arms 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 citys 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
View 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;
}

View 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
View 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
View 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'
: 'Lets 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-reportbased 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 well 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">
Well 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">Im still deciding</p>
<p className="text-sm text-white/50">Apply generally well 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 &amp; 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>
)
}

View 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">Couldnt 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>
)
}

View 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&apos;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&apos;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&apos;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
View 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
View 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>
)
}

File diff suppressed because it is too large Load Diff

21
src/types.ts Normal file
View 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
}

View 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
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_STRIPE_PUBLISHABLE_KEY?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}