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