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

47
.env.example Normal file
View File

@@ -0,0 +1,47 @@
# =============================================================================
# Sacred Villas — Stripe & app config
# Copy to `.env` and fill in. Never commit `.env` (it is gitignored).
# Dashboard: https://dashboard.stripe.com/apikeys
# =============================================================================
# -----------------------------------------------------------------------------
# REQUIRED for payments (embedded Checkout)
# -----------------------------------------------------------------------------
# Secret API key — starts with sk_test_ or sk_live_
# Server only. Creates Checkout sessions, retrieves sessions, verifies webhooks.
STRIPE_SECRET_KEY=
# Publishable key — starts with pk_test_ or pk_live_
# Loaded by Vite into the browser for Embedded Checkout on /apply/checkout.
# Safe to expose in the frontend bundle; never put the secret key in client code.
VITE_STRIPE_PUBLISHABLE_KEY=
# Public origin of your app (no trailing slash). Used in Checkout return_url.
# Local: http://localhost:5173
# Live: https://yourdomain.com
CLIENT_URL=
# -----------------------------------------------------------------------------
# RECOMMENDED for production (reliable server-side payment notifications)
# -----------------------------------------------------------------------------
# Webhook signing secret — starts with whsec_
# Create endpoint URL: https://yourdomain.com/api/webhooks/stripe
# Events: at least checkout.session.completed
# Local testing: stripe listen --forward-to localhost:4242/api/webhooks/stripe
STRIPE_WEBHOOK_SECRET=
# -----------------------------------------------------------------------------
# Optional
# -----------------------------------------------------------------------------
# PORT=4242
# NODE_ENV=production
# Random secret for listing/export of housing applications (JSON files under data/applications/).
# curl -H "Authorization: Bearer YOUR_TOKEN" https://your-host/api/admin/applications
# APPLICATIONS_ADMIN_TOKEN=
# Override where application JSON files are stored (default: ./data/applications under the app root).
# APPLICATIONS_DATA_DIR=/var/lib/sacred-villas/applications

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
/node_modules
/dist
dist-ssr
/data
*.local
.env
.env.*
!.env.example
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

14
README.md Normal file
View File

@@ -0,0 +1,14 @@
# Sacred Villas
React/Vite site with an Express API for rental applications and embedded Stripe Checkout.
## Commands
```bash
npm install
npm run dev
npm run lint
npm run build
```
Copy `.env.example` to `.env` and fill in the required Stripe/app settings. Do not commit `.env`, `data`, `dist`, or `node_modules`.

23
eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

23
index.html Normal file
View File

@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Sacred Villas — an enchanting collection of Seattle-area rental homes from lakeside hush to skyline spark. Explore residences and begin your application."
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,500;0,600;0,700;1,500&family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&display=swap"
rel="stylesheet"
/>
<title>Sacred Villas — Seattle-Area Homes, Gently Offered</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5421
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "sacred-villas",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "concurrently -k \"vite\" \"node server/index.mjs\"",
"dev:client": "vite",
"dev:api": "node server/index.mjs",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@stripe/react-stripe-js": "^6.2.0",
"@stripe/stripe-js": "^9.2.0",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"framer-motion": "^12.38.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.1",
"stripe": "^22.0.2"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/typography": "^0.5.19",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.21",
"concurrently": "^9.2.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^5.4.11"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

12
public/favicon.svg Normal file
View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#7eb8da"/>
<stop offset="100%" stop-color="#1a3a5c"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="#0a1220"/>
<path fill="url(#sky)" d="M8 46V22l10-8 8 6 12-14 10 10 8-6v32H8z" opacity=".9"/>
<path fill="#0f1f2c" d="M4 48h56v8H4v-8zm0-4 12-10 10 8 14-12 12 10 18-14v16H4V44z"/>
<path fill="#122820" d="M2 52h60v6H2v-6zm4-6 8-14 6 5 10-9 8 7 12-10 10 8v13H6V46z"/>
</svg>

After

Width:  |  Height:  |  Size: 591 B

24
public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,185 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import crypto from 'node:crypto'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const appsDir = process.env.APPLICATIONS_DATA_DIR
? path.resolve(process.env.APPLICATIONS_DATA_DIR)
: path.join(__dirname, '..', 'data', 'applications')
const REF_RE = /^SV-\d{8}-[A-HJ-NP-Z2-9]{6}$/
export function isValidApplicationRef(ref) {
return typeof ref === 'string' && REF_RE.test(ref)
}
export function makeApplicationRef() {
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const d = new Date()
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
let suffix = ''
for (let i = 0; i < 6; i += 1) {
suffix += alphabet[crypto.randomInt(alphabet.length)]
}
return `SV-${y}${m}${day}-${suffix}`
}
export function makeApplicationSecret() {
return crypto.randomBytes(24).toString('base64url')
}
export function hashApplicationSecret(secret) {
return crypto.createHash('sha256').update(String(secret)).digest('hex')
}
async function ensureDir() {
await fs.mkdir(appsDir, { recursive: true, mode: 0o700 })
await fs.chmod(appsDir, 0o700)
}
function filePath(ref) {
if (!isValidApplicationRef(ref)) {
throw new Error('Invalid application reference.')
}
return path.join(appsDir, `${ref}.json`)
}
/**
* @param {string} ref
* @returns {Promise<object | null>}
*/
export async function readApplication(ref) {
try {
const raw = await fs.readFile(filePath(ref), 'utf8')
return JSON.parse(raw)
} catch {
return null
}
}
/**
* @param {object} record
*/
export async function writeApplication(record) {
const ref = record.applicationRef
await ensureDir()
const tmp = `${filePath(ref)}.tmp`
const finalPath = filePath(ref)
const body = JSON.stringify(record)
await fs.writeFile(tmp, body, { encoding: 'utf8', mode: 0o600 })
await fs.rename(tmp, finalPath)
await fs.chmod(finalPath, 0o600)
}
/**
* @param {object} patch
*/
export async function createApplicationRecord({ payload = {} } = {}) {
await ensureDir()
let ref = makeApplicationRef()
for (let attempt = 0; attempt < 8; attempt += 1) {
try {
await fs.access(filePath(ref))
ref = makeApplicationRef()
} catch {
break
}
}
const now = new Date().toISOString()
const applicationSecret = makeApplicationSecret()
const record = {
applicationRef: ref,
applicationSecretHash: hashApplicationSecret(applicationSecret),
createdAt: now,
updatedAt: now,
status: 'draft',
stripeSessionId: null,
paymentType: null,
applicantEmail: '',
applicantPhone: '',
payload,
}
await writeApplication(record)
return { ...record, applicationSecret }
}
/**
* @param {string} ref
* @param {object} updates
*/
export async function updateApplication(ref, updates) {
const existing = await readApplication(ref)
if (!existing) return null
const next = {
...existing,
...updates,
applicationRef: ref,
updatedAt: new Date().toISOString(),
}
await writeApplication(next)
return next
}
/**
* @param {number} limit
*/
export async function listApplicationSummaries(limit = 80) {
await ensureDir()
let names
try {
names = await fs.readdir(appsDir)
} catch {
return []
}
const jsonFiles = names.filter((n) => n.endsWith('.json') && n.startsWith('SV-'))
const rows = await Promise.all(
jsonFiles.map(async (name) => {
const full = path.join(appsDir, name)
try {
const st = await fs.stat(full)
return { full, mtime: st.mtimeMs }
} catch {
return null
}
}),
)
const sorted = rows.filter(Boolean).sort((a, b) => b.mtime - a.mtime)
const out = []
for (const row of sorted.slice(0, limit)) {
try {
const raw = await fs.readFile(row.full, 'utf8')
const doc = JSON.parse(raw)
const email =
doc.payload?.identity?.email ||
doc.payload?.identity?.applicantEmail ||
doc.applicantEmail ||
''
out.push({
applicationRef: doc.applicationRef,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
status: doc.status,
paymentType: doc.paymentType ?? doc.payload?.prefs?.paymentPath ?? null,
applicantEmail: email,
})
} catch {
/* skip corrupt */
}
}
return out
}
/**
* @param {string} ref
* @param {string} sessionId
*/
export async function markPaidFromStripe(ref, sessionId) {
return updateApplication(ref, {
status: 'paid',
stripeSessionId: sessionId,
})
}

440
server/index.mjs Normal file
View File

@@ -0,0 +1,440 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import crypto from 'node:crypto'
import express from 'express'
import cors from 'cors'
import Stripe from 'stripe'
import dotenv from 'dotenv'
import * as applications from './applicationsStore.mjs'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
dotenv.config({ path: path.join(__dirname, '..', '.env') })
const distDir = path.join(__dirname, '..', 'dist')
const isProd = process.env.NODE_ENV === 'production'
const PORT = Number(process.env.PORT) || 4242
const defaultClientUrl = isProd
? `http://127.0.0.1:${PORT}`
: 'http://localhost:5173'
const clientUrl = (process.env.CLIENT_URL || defaultClientUrl).replace(/\/$/, '')
const allowedOrigins = new Set([
clientUrl,
'http://localhost:5173',
'http://127.0.0.1:5173',
`http://localhost:${PORT}`,
`http://127.0.0.1:${PORT}`,
'http://home.thetempleofdoom.com',
'https://home.thetempleofdoom.com',
])
process.umask(0o077)
const app = express()
app.disable('x-powered-by')
app.set('trust proxy', true)
app.use(
cors({
origin(origin, cb) {
if (!origin || allowedOrigins.has(origin)) {
cb(null, true)
return
}
cb(null, false)
},
}),
)
app.use((_, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('X-Frame-Options', 'SAMEORIGIN')
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
next()
})
function rateLimit({ windowMs, max }) {
const buckets = new Map()
return (req, res, next) => {
const key = req.ip || req.socket.remoteAddress || 'unknown'
const now = Date.now()
const bucket = buckets.get(key)
if (!bucket || bucket.resetAt <= now) {
buckets.set(key, { count: 1, resetAt: now + windowMs })
next()
return
}
bucket.count += 1
if (bucket.count > max) {
res.status(429).json({ error: 'Too many requests. Please try again later.' })
return
}
next()
}
}
function hasValidApplicationSecret(req, record) {
const secret = req.get('x-application-secret') || req.body?.applicationSecret
const storedHash = record?.applicationSecretHash
if (!secret || !storedHash) return false
const providedHash = applications.hashApplicationSecret(secret)
return crypto.timingSafeEqual(Buffer.from(providedHash), Buffer.from(storedHash))
}
const applicationInitLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 })
const checkoutLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 40 })
const stripeSecret = process.env.STRIPE_SECRET_KEY
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET
/** Webhooks need the raw body for signature verification — must run before express.json(). */
app.post(
'/api/webhooks/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
if (!stripeSecret) {
return res.status(503).send('Stripe secret not configured.')
}
if (!webhookSecret) {
return res.status(503).send('STRIPE_WEBHOOK_SECRET not set.')
}
const sig = req.headers['stripe-signature']
if (!sig || typeof sig !== 'string') {
return res.status(400).send('Missing stripe-signature header.')
}
let event
try {
const stripe = new Stripe(stripeSecret)
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Invalid payload'
console.error('Webhook signature verification failed:', msg)
return res.status(400).send(`Webhook Error: ${msg}`)
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object
console.info(
'[Stripe] checkout.session.completed',
session.id,
session.metadata?.house_name || '',
)
{
const ref = session.metadata?.application_ref
if (ref && applications.isValidApplicationRef(ref)) {
void applications.markPaidFromStripe(ref, session.id).catch((err) => {
console.error('[applications] mark paid failed', err)
})
}
}
break
}
default:
break
}
res.json({ received: true })
},
)
app.use(express.json({ limit: '1mb' }))
app.get('/api/health', (_, res) => {
const payload = {
ok: true,
site: 'Sacred Villas',
uptime_s: Math.round(process.uptime()),
}
if (!isProd) {
payload.stripe = {
secret_key_configured: Boolean(stripeSecret),
publishable_key_configured: Boolean(process.env.VITE_STRIPE_PUBLISHABLE_KEY),
webhook_secret_configured: Boolean(webhookSecret),
}
payload.client_url_configured = Boolean(process.env.CLIENT_URL)
payload.applications_store = 'file'
}
res.json(payload)
})
app.post('/api/applications/init', applicationInitLimiter, async (_, res) => {
try {
const record = await applications.createApplicationRecord({
payload: { version: 1 },
})
res.json({
applicationRef: record.applicationRef,
applicationSecret: record.applicationSecret,
createdAt: record.createdAt,
})
} catch (err) {
console.error(err)
res.status(500).json({ error: 'Could not create application file.' })
}
})
app.put('/api/applications/:ref', async (req, res) => {
try {
const { ref } = req.params
if (!applications.isValidApplicationRef(ref)) {
return res.status(400).json({ error: 'Invalid application reference.' })
}
const existing = await applications.readApplication(ref)
if (!existing) {
return res.status(404).json({ error: 'Application not found.' })
}
if (!hasValidApplicationSecret(req, existing)) {
return res.status(401).json({ error: 'Invalid application credentials.' })
}
if (existing.status === 'paid') {
return res.status(409).json({ error: 'Paid applications cannot be changed.' })
}
const payload = req.body?.payload
if (!payload || typeof payload !== 'object') {
return res.status(400).json({ error: 'payload object is required.' })
}
const identity = payload.identity ?? {}
const nextStatus = req.body?.status
let status = existing.status
if (nextStatus === 'submitted') status = 'submitted'
else if (nextStatus === 'draft') status = 'draft'
const updated = await applications.updateApplication(ref, {
payload,
applicantEmail: String(identity.email ?? '').slice(0, 256),
applicantPhone: String(identity.primaryPhone ?? '').slice(0, 80),
paymentType: payload.prefs?.paymentPath
? String(payload.prefs.paymentPath).slice(0, 32)
: existing.paymentType,
status,
})
res.json({
ok: true,
applicationRef: updated.applicationRef,
updatedAt: updated.updatedAt,
status: updated.status,
})
} catch (err) {
console.error(err)
res.status(500).json({ error: 'Could not save application.' })
}
})
app.post('/api/applications/sync-payment', checkoutLimiter, async (req, res) => {
try {
if (!stripeSecret) {
return res.status(503).json({ error: 'Stripe is not configured on this host.' })
}
const sessionId = req.body?.sessionId
if (!sessionId || typeof sessionId !== 'string') {
return res.status(400).json({ error: 'sessionId is required.' })
}
const stripe = new Stripe(stripeSecret)
const session = await stripe.checkout.sessions.retrieve(String(sessionId).slice(0, 128))
const ref = session.metadata?.application_ref
if (!ref || !applications.isValidApplicationRef(ref)) {
return res.json({ ok: false, reason: 'no_application_ref' })
}
if (session.status === 'complete' && session.payment_status === 'paid') {
await applications.markPaidFromStripe(ref, session.id)
return res.json({ ok: true, applicationRef: ref })
}
res.json({
ok: false,
reason: 'not_paid',
status: session.status,
payment_status: session.payment_status,
})
} catch (err) {
console.error(err)
res.status(500).json({ error: 'Unable to sync payment status.' })
}
})
function assertApplicationsAdmin(req, res) {
const token = process.env.APPLICATIONS_ADMIN_TOKEN
if (!token) {
res.status(503).json({
error:
'Admin listing is disabled. Set APPLICATIONS_ADMIN_TOKEN in the server environment to enable it.',
})
return false
}
const auth = req.headers.authorization || ''
const bearer = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''
if (bearer !== token) {
res.status(401).json({ error: 'Unauthorized.' })
return false
}
return true
}
app.get('/api/admin/applications', async (req, res) => {
if (!assertApplicationsAdmin(req, res)) return
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 80))
const rows = await applications.listApplicationSummaries(limit)
res.json({ applications: rows })
})
app.get('/api/admin/applications/:ref', async (req, res) => {
if (!assertApplicationsAdmin(req, res)) return
const { ref } = req.params
if (!applications.isValidApplicationRef(ref)) {
return res.status(400).json({ error: 'Invalid application reference.' })
}
const doc = await applications.readApplication(ref)
if (!doc) {
return res.status(404).json({ error: 'Not found.' })
}
res.json(doc)
})
app.post('/api/create-checkout-session', checkoutLimiter, async (req, res) => {
try {
if (!stripeSecret) {
return res.status(503).json({
error:
'Payments are not configured yet. Add STRIPE_SECRET_KEY on the server.',
})
}
const stripe = new Stripe(stripeSecret)
const {
applicationRef,
paymentType,
houseId,
houseName,
houseArea,
customerEmail,
fullName,
phone,
notes,
} = req.body || {}
if (!applicationRef || typeof applicationRef !== 'string') {
return res.status(400).json({
error: 'applicationRef is required. Return to /apply and use the housing application wizard.',
})
}
if (!applications.isValidApplicationRef(applicationRef)) {
return res.status(400).json({ error: 'Invalid application reference format.' })
}
const appRow = await applications.readApplication(applicationRef)
if (!appRow) {
return res.status(400).json({
error: 'Unknown application reference. Please restart from the application page.',
})
}
if (!hasValidApplicationSecret(req, appRow)) {
return res.status(401).json({ error: 'Invalid application credentials.' })
}
if (paymentType !== 'deposit' && paymentType !== 'credit') {
return res.status(400).json({ error: 'Invalid payment type.' })
}
if (!customerEmail || typeof customerEmail !== 'string') {
return res.status(400).json({ error: 'Email is required.' })
}
if (!fullName || typeof fullName !== 'string') {
return res.status(400).json({ error: 'Full name is required.' })
}
const isDeposit = paymentType === 'deposit'
const unitAmount = isDeposit ? 50000 : 7500
const productName = isDeposit
? 'Holding deposit — Sacred Villas'
: 'Applicant credit screening — Sacred Villas'
const propertyLine = houseName
? `Property: ${String(houseName).slice(0, 180)}`
: 'Property: to be assigned with your concierge'
const lineDescription =
`${propertyLine}. Applicant: ${String(fullName).slice(0, 100)}. File: ${String(applicationRef).slice(0, 32)}`.slice(
0,
500,
)
const session = await stripe.checkout.sessions.create({
ui_mode: 'embedded_page',
mode: 'payment',
customer_email: customerEmail.trim().slice(0, 256),
line_items: [
{
price_data: {
currency: 'usd',
unit_amount: unitAmount,
product_data: {
name: productName.slice(0, 120),
description: lineDescription,
},
},
quantity: 1,
},
],
metadata: {
application_ref: String(applicationRef).slice(0, 40),
payment_type: paymentType,
house_id: houseId ? String(houseId).slice(0, 120) : '',
house_name: houseName ? String(houseName).slice(0, 120) : '',
seattle_area: houseArea ? String(houseArea).slice(0, 120) : '',
full_name: String(fullName).slice(0, 120),
phone: phone ? String(phone).slice(0, 40) : '',
notes: notes ? String(notes).slice(0, 500) : '',
},
return_url: `${clientUrl}/apply/return?session_id={CHECKOUT_SESSION_ID}`,
})
if (!session.client_secret) {
return res.status(500).json({
error: 'Checkout session did not return a client secret.',
})
}
res.json({ clientSecret: session.client_secret })
} catch (err) {
console.error(err)
res.status(500).json({ error: 'Unable to start checkout.' })
}
})
app.get('/api/checkout-session-status', checkoutLimiter, async (req, res) => {
try {
if (!stripeSecret) {
return res.status(503).json({
error:
'Payments are not configured yet. Add STRIPE_SECRET_KEY on the server.',
})
}
const sessionId = req.query.session_id
if (!sessionId || typeof sessionId !== 'string') {
return res.status(400).json({ error: 'session_id query parameter is required.' })
}
const stripe = new Stripe(stripeSecret)
const session = await stripe.checkout.sessions.retrieve(String(sessionId).slice(0, 128))
res.json({
status: session.status,
payment_status: session.payment_status,
customer_email: session.customer_details?.email ?? null,
application_ref: session.metadata?.application_ref ?? null,
})
} catch (err) {
console.error(err)
res.status(500).json({ error: 'Unable to load session.' })
}
})
if (isProd && fs.existsSync(distDir)) {
app.use(express.static(distDir))
// Express 5 / path-to-regexp: named splat, not bare '*'
app.get('/{*splat}', (req, res) => {
res.sendFile(path.join(distDir, 'index.html'))
})
}
app.listen(PORT, '0.0.0.0', () => {
console.log(`Sacred Villas API ${isProd ? '+ static' : ''} on http://0.0.0.0:${PORT}`)
})

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
}

23
tailwind.config.js Normal file
View File

@@ -0,0 +1,23 @@
import typography from '@tailwindcss/typography'
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
fontFamily: {
display: ['"Cormorant Garamond"', 'ui-serif', 'Georgia', 'serif'],
sans: ['"DM Sans"', 'ui-sans-serif', 'system-ui', 'sans-serif'],
},
colors: {
'villa-ink': '#0f1729',
'villa-mist': '#e8f0f7',
'villa-sage': '#6b8f71',
'villa-gold': '#c9a227',
'villa-sky': '#7eb8da',
'villa-pine': '#1e3d32',
},
},
},
plugins: [typography],
}

25
tsconfig.app.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
tsconfig.node.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

23
vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
const apiProxy = {
'/api': {
target: 'http://127.0.0.1:4242',
changeOrigin: true,
},
}
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 5173,
proxy: apiProxy,
},
// `vite preview` does not inherit dev server proxy unless set here
preview: {
proxy: apiProxy,
},
})